diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index 2aa482e..d827813 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -30,6 +30,7 @@ export interface AttributePanelState extends SceneObjectState { title: string; type: HomepagePanelType; renderDurationPanel?: boolean; + filter?: string; } export class AttributePanel extends SceneObjectBase { @@ -37,7 +38,7 @@ export class AttributePanel extends SceneObjectBase { super({ $data: new SceneQueryRunner({ datasource: explorationDS, - queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query }], + queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query, exemplars: 0 }], }), ...state, }); @@ -86,7 +87,7 @@ export class AttributePanel extends SceneObjectBase { children: [ new AttributePanel({ query: { - query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}}`, + query: `{nestedSetParent<0 && duration > ${minDuration} ${state.filter ?? ''}}`, }, title: state.title, type: state.type, diff --git a/src/components/Home/AttributePanelRows.test.tsx b/src/components/Home/AttributePanelRows.test.tsx index 9e4249f..c61bc23 100644 --- a/src/components/Home/AttributePanelRows.test.tsx +++ b/src/components/Home/AttributePanelRows.test.tsx @@ -4,11 +4,12 @@ import { AttributePanelRows } from './AttributePanelRows'; import { DataFrame, Field } from '@grafana/data'; describe('AttributePanelRows', () => { - const createField = (name: string, values: any[], labels: Record = {}) => + const createField = (name: string, values: any[], labels: Record = {}, type?: string) => ({ name, values, labels, + type, }) as Field; const createDataFrame = (fields: Field[]) => @@ -19,11 +20,11 @@ describe('AttributePanelRows', () => { const dummySeries = [ createDataFrame([ createField('time', []), - createField('Test service 1', [10, 20], { 'resource.service.name': '"Test service 1"' }), + createField('Test service 1', [10, 20], { 'resource.service.name': '"Test service 1"' }, 'number'), ]), createDataFrame([ createField('time', []), - createField('Test service 2', [15, 5], { 'resource.service.name': '"Test service 2"' }), + createField('Test service 2', [15, 5], { 'resource.service.name': '"Test service 2"' }, 'number'), ]), ]; diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index f1f7ac1..d72d6b7 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -11,14 +11,14 @@ import { Button, Icon, LinkButton, Stack, useStyles2, useTheme2 } from '@grafana import { EXPLORATIONS_ROUTE, } from '../../utils/shared'; -import { getDatasourceVariable, getHomeScene } from '../../utils/utils'; +import { getDatasourceVariable, getHomeFilterVariable, getHomeScene } from '../../utils/utils'; import { useNavigate } from 'react-router-dom-v5-compat'; -import { Home } from 'pages/Home/Home'; import { DarkModeRocket, LightModeRocket } from '../../utils/rockets'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; +import { Home } from 'pages/Home/Home'; export class HeaderScene extends SceneObjectBase { - static Component = ({ model }: SceneComponentProps) => { + public static Component = ({ model }: SceneComponentProps) => { const home = getHomeScene(model); const navigate = useNavigate(); const { controls } = home.useState(); @@ -26,6 +26,7 @@ export class HeaderScene extends SceneObjectBase { const theme = useTheme2(); const dsVariable = getDatasourceVariable(home); + const filterVariable = getHomeFilterVariable(home); return (
@@ -66,16 +67,27 @@ export class HeaderScene extends SceneObjectBase {
- {dsVariable && ( - -
Data source
- -
- )} -
- {controls.map((control) => ( - - ))} +
+
+ {dsVariable && ( + +
Data source
+ +
+ )} + {filterVariable && ( + +
Filter
+ +
+ )} +
+ +
+ {controls?.map((control) => ( + + ))} +
@@ -130,9 +142,20 @@ function getStyles(theme: GrafanaTheme2) { } }), - datasourceLabel: css({ + label: css({ fontSize: '12px', }), + variablesAndControls: css({ + alignItems: 'center', + gap: theme.spacing(2), + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }), + variables: css({ + display: 'flex', + gap: theme.spacing(2), + }), controls: css({ display: 'flex', gap: theme.spacing(1), diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index d3a5e60..ccfec7e 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -3,8 +3,9 @@ import React from 'react'; // eslint-disable-next-line no-restricted-imports import { duration } from 'moment'; -import { GrafanaTheme2 } from '@grafana/data'; +import { AdHocVariableFilter, GrafanaTheme2 } from '@grafana/data'; import { + AdHocFiltersVariable, DataSourceVariable, SceneComponentProps, SceneCSSGridItem, @@ -21,22 +22,31 @@ import { } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; -import { DATASOURCE_LS_KEY, VAR_DATASOURCE } from '../../utils/shared'; +import { + DATASOURCE_LS_KEY, + explorationDS, + HOMEPAGE_FILTERS_LS_KEY, + VAR_DATASOURCE, + VAR_HOME_FILTER, +} from '../../utils/shared'; import { AttributePanel } from 'components/Home/AttributePanel'; import { HeaderScene } from 'components/Home/HeaderScene'; -import { getDatasourceVariable } from 'utils/utils'; +import { getDatasourceVariable, getHomeFilterVariable } from 'utils/utils'; +import { reportAppInteraction, USER_EVENTS_PAGES, USER_EVENTS_ACTIONS } from 'utils/analytics'; +import { getTagKeysProvider, renderTraceQLLabelFilters } from './utils'; export interface HomeState extends SceneObjectState { - controls: SceneObject[]; + controls?: SceneObject[]; initialDS?: string; + initialFilters: AdHocVariableFilter[]; body?: SceneCSSGridLayout; } export class Home extends SceneObjectBase { - public constructor(state: Partial) { + public constructor(state: HomeState) { super({ $timeRange: state.$timeRange ?? new SceneTimeRange({}), - $variables: state.$variables ?? getVariableSet(state.initialDS), + $variables: state.$variables ?? getVariableSet(state.initialFilters, state.initialDS), controls: state.controls ?? [new SceneTimePicker({}), new SceneRefreshPicker({})], ...state, }); @@ -45,26 +55,48 @@ export class Home extends SceneObjectBase { } private _onActivate() { + const sceneTimeRange = sceneGraph.getTimeRange(this); + const filterVariable = getHomeFilterVariable(this); + filterVariable.setState({ + getTagKeysProvider: getTagKeysProvider, + }); + getDatasourceVariable(this).subscribeToState((newState) => { if (newState.value) { localStorage.setItem(DATASOURCE_LS_KEY, newState.value.toString()); } }); - const sceneTimeRange = sceneGraph.getTimeRange(this); + getHomeFilterVariable(this).subscribeToState((newState, prevState) => { + if (newState.filters !== prevState.filters) { + this.buildPanels(sceneTimeRange, newState.filters); + + // save the filters to local storage + localStorage.setItem(HOMEPAGE_FILTERS_LS_KEY, JSON.stringify(newState.filters)); + + const newFilters = newState.filters.filter((f) => !prevState.filters.find((pf) => pf.key === f.key)); + if (newFilters.length > 0) { + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.filter_changed, { + key: newFilters[0].key, + }); + } + } + }); + sceneTimeRange.subscribeToState((newState, prevState) => { if (newState.value.from !== prevState.value.from || newState.value.to !== prevState.value.to) { - this.buildPanels(sceneTimeRange); + this.buildPanels(sceneTimeRange, filterVariable.state.filters); } }); - this.buildPanels(sceneTimeRange); + this.buildPanels(sceneTimeRange, filterVariable.state.filters); } - buildPanels(sceneTimeRange: SceneTimeRangeLike) { + buildPanels(sceneTimeRange: SceneTimeRangeLike, filters: AdHocVariableFilter[]) { const from = sceneTimeRange.state.value.from.unix(); const to = sceneTimeRange.state.value.to.unix(); const dur = duration(to - from, 's'); const durString = `${dur.asSeconds()}s`; + const renderedFilters = renderTraceQLLabelFilters(filters); this.setState({ body: new SceneCSSGridLayout({ @@ -77,7 +109,7 @@ export class Home extends SceneObjectBase { new SceneCSSGridItem({ body: new AttributePanel({ query: { - query: '{nestedSetParent < 0 && status = error} | count_over_time() by (resource.service.name)', + query: `{nestedSetParent < 0 && status = error ${renderedFilters}} | count_over_time() by (resource.service.name)`, step: durString, }, title: 'Errored services', @@ -97,10 +129,11 @@ export class Home extends SceneObjectBase { new SceneCSSGridItem({ body: new AttributePanel({ query: { - query: '{nestedSetParent<0} | histogram_over_time(duration)', + query: `{nestedSetParent<0 ${renderedFilters}} | histogram_over_time(duration)`, }, title: 'Slow traces', type: 'slowest-traces', + filter: renderedFilters, }), }), ], @@ -123,7 +156,7 @@ export class Home extends SceneObjectBase { }; } -function getVariableSet(initialDS?: string) { +function getVariableSet(initialFilters: AdHocVariableFilter[], initialDS?: string) { return new SceneVariableSet({ variables: [ new DataSourceVariable({ @@ -132,6 +165,13 @@ function getVariableSet(initialDS?: string) { value: initialDS, pluginId: 'tempo', }), + new AdHocFiltersVariable({ + name: VAR_HOME_FILTER, + datasource: explorationDS, + layout: 'combobox', + filters: initialFilters, + allowCustomValue: true, + }), ], }); } diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx index 8cb9416..e75205d 100644 --- a/src/pages/Home/HomePage.tsx +++ b/src/pages/Home/HomePage.tsx @@ -1,12 +1,14 @@ import React, { useEffect, useState } from 'react'; import { newHome } from '../../utils/utils'; -import { DATASOURCE_LS_KEY } from '../../utils/shared'; +import { DATASOURCE_LS_KEY, HOMEPAGE_FILTERS_LS_KEY } from '../../utils/shared'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../utils/analytics'; import { Home } from './Home'; const HomePage = () => { const initialDs = localStorage.getItem(DATASOURCE_LS_KEY) || ''; - const [home] = useState(newHome(initialDs)); + const localStorageFilters = localStorage.getItem(HOMEPAGE_FILTERS_LS_KEY); + const filters = localStorageFilters ? JSON.parse(localStorageFilters) : []; + const [home] = useState(newHome(filters, initialDs)); return ; }; diff --git a/src/pages/Home/utils.test.ts b/src/pages/Home/utils.test.ts new file mode 100644 index 0000000..f2d90c2 --- /dev/null +++ b/src/pages/Home/utils.test.ts @@ -0,0 +1,72 @@ +import { MetricFindValue } from "@grafana/data"; +import { filterKeys, renderTraceQLLabelFilters } from "./utils"; +import { EVENT_ATTR, EVENT_INTRINSIC, ignoredAttributes, ignoredAttributesHomeFilter } from "utils/shared"; + +describe('filterKeys', () => { + it('should handle an empty input array', () => { + const filteredKeys = filterKeys([]); + expect(filteredKeys).toEqual([]); + }); + + it('should return correct keys', () => { + const mockKeys: MetricFindValue[] = [ + { text: 'resource.cpu' }, + { text: 'resource.memory' }, + { text: 'span.duration' }, + { text: 'span.name' }, + { text: ignoredAttributes[0] }, + { text: ignoredAttributesHomeFilter[0] }, + { text: `${EVENT_ATTR}timestamp` }, + { text: `${EVENT_INTRINSIC}timestamp` }, + ]; + + const filteredKeys = filterKeys(mockKeys); + + expect(filteredKeys).toEqual([ + { text: 'resource.cpu' }, + { text: 'resource.memory' }, + { text: 'span.duration' }, + { text: 'span.name' }, + ]); + }); + + it('should order keys correctly', () => { + const mockKeys: MetricFindValue[] = [ + { text: ignoredAttributes[0] }, + { text: ignoredAttributesHomeFilter[0] }, + { text: 'span.duration' }, + { text: 'span.name' }, + { text: `${EVENT_ATTR}timestamp` }, + { text: `${EVENT_INTRINSIC}timestamp` }, + { text: 'resource.cpu' }, + { text: 'resource.memory' }, + ]; + + const filteredKeys = filterKeys(mockKeys); + + expect(filteredKeys).toEqual([ + { text: 'resource.cpu' }, + { text: 'resource.memory' }, + { text: 'span.duration' }, + { text: 'span.name' }, + ]); + }); +}); + +describe('renderTraceQLLabelFilters', () => { + it('returns an empty string when value is empty', () => { + expect(renderTraceQLLabelFilters([{ key: 'testKey', operator: '=', value: '' }])).toBe(''); + }); + + it('wraps value in quotes when key matches RESOURCE_ATTR or SPAN_ATTR and value is not a number', () => { + expect(renderTraceQLLabelFilters([{ key: 'resource.attr', operator: '=', value: 'testValue' }, { key: 'span.attr', operator: '=', value: 'testValue' }])).toBe('&& resource.attr="testValue" && span.attr="testValue"'); + }); + + it('does not wrap value in quotes when it is already quoted', () => { + expect(renderTraceQLLabelFilters([{ key: 'resource.attr', operator: '=', value: '"testValue"' }])).toBe('&& resource.attr="testValue"'); + }); + + it('does not wrap number in quotes', () => { + expect(renderTraceQLLabelFilters([{ key: 'custom.attr', operator: '=', value: '123' }])).toBe('&& custom.attr=123'); + }); +}); diff --git a/src/pages/Home/utils.ts b/src/pages/Home/utils.ts new file mode 100644 index 0000000..b8a7567 --- /dev/null +++ b/src/pages/Home/utils.ts @@ -0,0 +1,68 @@ +import { AdHocVariableFilter, MetricFindValue } from "@grafana/data"; +import { getDataSourceSrv, DataSourceWithBackend } from "@grafana/runtime"; +import { AdHocFiltersVariable, sceneGraph } from "@grafana/scenes"; +import { EVENT_ATTR, EVENT_INTRINSIC, ignoredAttributes, ignoredAttributesHomeFilter, RESOURCE_ATTR, SPAN_ATTR, VAR_DATASOURCE_EXPR } from "utils/shared"; +import { isNumber } from "utils/utils"; + +export async function getTagKeysProvider(variable: AdHocFiltersVariable): Promise<{replace?: boolean, values: MetricFindValue[]}> { + const dsVar = sceneGraph.interpolate(variable, VAR_DATASOURCE_EXPR); + const datasource_ = await getDataSourceSrv().get(dsVar); + if (!(datasource_ instanceof DataSourceWithBackend)) { + console.error(new Error('getTagKeysProvider: invalid datasource!')); + throw new Error('getTagKeysProvider: invalid datasource!'); + } + + const datasource = datasource_ as DataSourceWithBackend; + if (datasource && datasource.getTagKeys) { + const tagKeys = await datasource.getTagKeys(); + + if (Array.isArray(tagKeys)) { + const filteredKeys = filterKeys(tagKeys); + return { replace: true, values: filteredKeys }; + } else { + console.error(new Error('getTagKeysProvider: invalid tagKeys!')); + return { values: [] }; + } + } else { + console.error(new Error('getTagKeysProvider: missing or invalid datasource!')); + return { values: [] }; + } +} + +export function filterKeys(keys: MetricFindValue[]): MetricFindValue[] { + const resourceAttributes = keys.filter((k) => k.text?.includes(RESOURCE_ATTR)); + const spanAttributes = keys.filter((k) => k.text?.includes(SPAN_ATTR)); + const otherAttributes = keys.filter((k) => { + return !k.text?.includes(RESOURCE_ATTR) && !k.text?.includes(SPAN_ATTR) + && !k.text?.includes(EVENT_ATTR) && !k.text?.includes(EVENT_INTRINSIC) + && ignoredAttributes.concat(ignoredAttributesHomeFilter).indexOf(k.text!) === -1; + }) + return [...resourceAttributes, ...spanAttributes, ...otherAttributes]; +} + +export function renderTraceQLLabelFilters(filters: AdHocVariableFilter[]) { + const expr = filters + .filter((f) => f.key && f.operator && f.value) + .map((filter) => renderFilter(filter)) + .join(' && '); + return expr.length ? `&& ${expr}` : ''; +} + +const renderFilter = (filter: AdHocVariableFilter) => { + if (!filter) { + return ''; + } + + let val = filter.value; + if (val === undefined || val === null || val === '') { + return ''; + } + + if (!isNumber.test(val) && !['kind'].includes(filter.key)) { + if (typeof val === 'string' && !val.startsWith('"') && !val.endsWith('"')) { + val = `"${val}"`; + } + } + + return `${filter.key}${filter.operator}${val}`; +} diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 576e8df..42d7cc5 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -46,6 +46,7 @@ export const USER_EVENTS_ACTIONS = { panel_row_clicked: 'panel_row_clicked', explore_traces_clicked: 'explore_traces_clicked', read_documentation_clicked: 'read_documentation_clicked', + filter_changed: 'filter_changed', }, [USER_EVENTS_PAGES.common]: { metric_changed: 'metric_changed', diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 17374b0..2051574 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -13,6 +13,7 @@ export const PLUGIN_BASE_URL = `/a/${PLUGIN_ID}`; export const EXPLORATIONS_ROUTE = `${PLUGIN_BASE_URL}/${ROUTES.Explore}`; export const DATASOURCE_LS_KEY = 'grafana.explore.traces.datasource'; +export const HOMEPAGE_FILTERS_LS_KEY = 'grafana.explore.traces.homepage.filters'; export const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; @@ -23,6 +24,7 @@ export const VAR_DATASOURCE = 'ds'; export const VAR_DATASOURCE_EXPR = '${ds}'; export const VAR_FILTERS = 'filters'; export const VAR_FILTERS_EXPR = '${filters}'; +export const VAR_HOME_FILTER = 'homeFilter'; export const VAR_GROUPBY = 'groupBy'; export const VAR_METRIC = 'metric'; export const VAR_LATENCY_THRESHOLD = 'latencyThreshold'; @@ -36,6 +38,8 @@ export const RESOURCE = 'Resource'; export const SPAN = 'Span'; export const RESOURCE_ATTR = 'resource.'; export const SPAN_ATTR = 'span.'; +export const EVENT_ATTR = 'event.'; +export const EVENT_INTRINSIC = 'event:'; export const radioAttributesResource = [ // https://opentelemetry.io/docs/specs/semconv/resource/ @@ -75,6 +79,16 @@ export const ignoredAttributes = [ 'trace:id', 'traceDuration', ]; +export const ignoredAttributesHomeFilter = [ + 'status', + 'span:status', + 'rootName', + 'rootService', + 'rootServiceName', + 'trace:rootName', + 'trace:rootService', + 'trace:rootServiceName' +]; // Limit maximum options in select dropdowns for performance reasons export const maxOptions = 1000; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index baef740..7dc01bb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -19,6 +19,7 @@ import { VAR_DATASOURCE_EXPR, VAR_FILTERS, VAR_GROUPBY, + VAR_HOME_FILTER, VAR_LATENCY_PARTIAL_THRESHOLD, VAR_LATENCY_THRESHOLD, VAR_METRIC, @@ -54,9 +55,10 @@ export function newTracesExploration( }); } -export function newHome(initialDS?: string): Home { +export function newHome(initialFilters: AdHocVariableFilter[], initialDS?: string): Home { return new Home({ initialDS, + initialFilters, $timeRange: new SceneTimeRange({ from: 'now-30m', to: 'now' }), }); } @@ -66,7 +68,7 @@ export function getErrorMessage(data: SceneDataState) { } export function getNoDataMessage(context: string) { - return `No data for selected data source. Select another to see ${context}.`; + return `No data for selected data source and filter. Select another to see ${context}.`; } export function getUrlForExploration(exploration: TraceExploration) { @@ -90,6 +92,21 @@ export function getAttributesAsOptions(attributes: string[]) { return attributes.map((attribute) => ({ label: attribute, value: attribute })); } +export function getLabelKey(frame: DataFrame) { + const labels = frame.fields.find((f) => f.type === 'number')?.labels; + + if (!labels) { + return 'No labels'; + } + + const keys = Object.keys(labels); + if (keys.length === 0) { + return 'No labels'; + } + + return keys[0].replace(/"/g, ''); +} + export function getLabelValue(frame: DataFrame, labelName?: string) { const labels = frame.fields.find((f) => f.type === 'number')?.labels; @@ -145,6 +162,14 @@ export function getFiltersVariable(scene: SceneObject): AdHocFiltersVariable { return variable; } +export function getHomeFilterVariable(scene: SceneObject): AdHocFiltersVariable { + const variable = sceneGraph.lookupVariable(VAR_HOME_FILTER, scene); + if (!(variable instanceof AdHocFiltersVariable)) { + throw new Error('Home filter variable not found'); + } + return variable; +} + export function getDatasourceVariable(scene: SceneObject): DataSourceVariable { const variable = sceneGraph.lookupVariable(VAR_DATASOURCE, scene); if (!(variable instanceof DataSourceVariable)) {