diff --git a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx index ee08264b417..5ce78e36621 100644 --- a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx +++ b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { PromMetricsMetadataItem } from '@grafana/prometheus'; import { QueryVariable, @@ -6,6 +8,7 @@ import { SceneObjectBase, SceneObjectState, VariableDependencyConfig, + VariableValueOption, } from '@grafana/scenes'; import { Stack, Text, TextLink } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; @@ -14,7 +17,8 @@ import { getUnitFromMetric } from '../AutomaticMetricQueries/units'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; -import { VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared'; +import { updateOtelJoinWithGroupLeft } from '../otel/util'; +import { VAR_DATASOURCE_EXPR, VAR_GROUP_BY, VAR_OTEL_GROUP_LEFT } from '../shared'; import { getMetricSceneFor, getTrailFor } from '../utils'; export interface MetricOverviewSceneState extends SceneObjectState { @@ -51,6 +55,7 @@ export class MetricOverviewScene extends SceneObjectBase) => { const { metadata, metadataLoading } = model.useState(); const variable = model.getVariable(); const { loading: labelsLoading, options: labelOptions } = variable.useState(); - const { useOtelExperience } = getTrailFor(model).useState(); + let allLabelOptions = labelOptions; + + const trail = getTrailFor(model); + const { useOtelExperience } = trail.useState(); + + if (useOtelExperience) { + // when the group left variable is changed we should get all the resource attributes + labels + const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail)?.getValue(); + if (typeof resourceAttributes === 'string') { + const attributeArray: VariableValueOption[] = resourceAttributes + .split(',') + .map((el) => ({ label: el, value: el })); + allLabelOptions = attributeArray.concat(allLabelOptions); + } + } + + useEffect(() => { + if (useOtelExperience) { + // this will update the group left variable + model.updateOtelGroupLeft(); + } + }, [model, useOtelExperience]); // Get unit name from the metric name const metricScene = getMetricSceneFor(model); @@ -113,13 +147,13 @@ export class MetricOverviewScene extends SceneObjectBase {useOtelExperience ? ( - Metric attributes + Attributes ) : ( Labels )} - {labelOptions.length === 0 && 'Unable to fetch labels.'} - {labelOptions.map((l) => ( + {allLabelOptions.length === 0 && 'Unable to fetch labels.'} + {allLabelOptions.map((l) => ( ) => { diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index b908c25f801..e8d383d9558 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -1,8 +1,10 @@ import { css } from '@emotion/css'; import { isNumber, max, min, throttle } from 'lodash'; +import { useEffect } from 'react'; import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { + ConstantVariable, PanelBuilders, QueryVariable, SceneComponentProps, @@ -28,11 +30,20 @@ import { Trans } from 'app/core/internationalization'; import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine'; import { AutoQueryDef } from '../AutomaticMetricQueries/types'; import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; +import { DataTrail } from '../DataTrail'; import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; +import { updateOtelJoinWithGroupLeft } from '../otel/util'; import { ALL_VARIABLE_VALUE } from '../services/variables'; -import { MDP_METRIC_PREVIEW, trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; +import { + MDP_METRIC_PREVIEW, + trailDS, + VAR_FILTERS, + VAR_GROUP_BY, + VAR_GROUP_BY_EXP, + VAR_OTEL_GROUP_LEFT, +} from '../shared'; import { getColorByIndex, getTrailFor } from '../utils'; import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; @@ -108,6 +119,27 @@ export class LabelBreakdownScene extends SceneObjectBase { + // if otel changes + if (useOtelExperience !== oldState.useOtelExperience) { + this.updateBody(variable); + } + }) + ); + + // OTEL + const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); + if (resourceAttributes instanceof ConstantVariable) { + resourceAttributes?.subscribeToState((newState, oldState) => { + // wait for the resource attributes to be loaded + if (newState.value !== oldState.value) { + this.updateBody(variable); + } + }); + } + this.updateBody(variable); } @@ -181,17 +213,24 @@ export class LabelBreakdownScene extends SceneObjectBase = { loading: variable.state.loading, value: String(variable.state.value), - labels: options, + labels: allLabelOptions, error: variable.state.error, blockingMessage: undefined, }; if (!variable.state.loading && variable.state.options.length) { stateUpdate.body = variable.hasAllValue() - ? buildAllLayout(options, this._query!, this.onBreakdownLayoutChange) + ? buildAllLayout(allLabelOptions, this._query!, this.onBreakdownLayoutChange, trail.state.useOtelExperience) : buildNormalLayout(this._query!, this.onBreakdownLayoutChange, this.state.search); } else if (!variable.state.loading) { stateUpdate.body = undefined; @@ -218,19 +257,67 @@ export class LabelBreakdownScene extends SceneObjectBase> { + // when the group left variable is changed we should get all the resource attributes + labels + const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail)?.getValue(); + if (typeof resourceAttributes !== 'string') { + return []; + } + + const attributeArray: SelectableValue[] = resourceAttributes.split(',').map((el) => ({ label: el, value: el })); + // shift ALL value to the front + const all: SelectableValue = [{ label: 'All', value: ALL_VARIABLE_VALUE }]; + const firstGroup = all.concat(attributeArray); + + // remove duplicates of ALL option + allLabelOptions = allLabelOptions.filter((option) => option.value !== ALL_VARIABLE_VALUE); + allLabelOptions = firstGroup.concat(allLabelOptions); + + return allLabelOptions; + } + public static Component = ({ model }: SceneComponentProps) => { const { labels, body, search, loading, value, blockingMessage } = model.useState(); const styles = useStyles2(getStyles); - const { useOtelExperience } = getTrailFor(model).useState(); + const trail = getTrailFor(model); + const { useOtelExperience } = trail.useState(); + + let allLabelOptions = labels; + if (trail.state.useOtelExperience) { + // All value moves to the middle because it is part of the label options variable + const all: SelectableValue = [{ label: 'All', value: ALL_VARIABLE_VALUE }]; + allLabelOptions.filter((option) => option.value !== ALL_VARIABLE_VALUE).unshift(all); + } + + useEffect(() => { + if (useOtelExperience) { + // this will update the group left variable + model.updateOtelGroupLeft(); + } + }, [model, useOtelExperience]); return (
- {!loading && Boolean(labels.length) && ( - - + {!loading && labels.length && ( + + )} @@ -282,7 +369,8 @@ function getStyles(theme: GrafanaTheme2) { export function buildAllLayout( options: Array>, queryDef: AutoQueryDef, - onBreakdownLayoutChange: BreakdownLayoutChangeCallback + onBreakdownLayoutChange: BreakdownLayoutChangeCallback, + useOtelExperience?: boolean ) { const children: SceneFlexItemLike[] = []; @@ -460,7 +548,16 @@ interface SelectLabelActionState extends SceneObjectState { export class SelectLabelAction extends SceneObjectBase { public onClick = () => { const label = this.state.labelName; - reportExploreMetrics('label_selected', { label, cause: 'breakdown_panel' }); + + // check that it is resource or label and update the rudderstack event + const trail = getTrailFor(this); + const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail)?.getValue(); + let otel_resource_attribute = false; + if (typeof resourceAttributes === 'string') { + otel_resource_attribute = resourceAttributes?.split(',').includes(label); + } + + reportExploreMetrics('label_selected', { label, cause: 'breakdown_panel', otel_resource_attribute }); getBreakdownSceneFor(this).onChange(label); }; diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index 58c42c65d5e..ce5b53fcdf2 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -13,6 +13,7 @@ import { MetricSelectedEvent, VAR_FILTERS, VAR_OTEL_DEPLOYMENT_ENV, + VAR_OTEL_GROUP_LEFT, VAR_OTEL_JOIN_QUERY, VAR_OTEL_RESOURCES, } from './shared'; @@ -507,12 +508,21 @@ describe('DataTrail', () => { throw new Error('getOtelResourcesVar failed'); } + function getOtelGroupLeftVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); + if (variable instanceof ConstantVariable) { + return variable; + } + throw new Error('getOtelResourcesVar failed'); + } + beforeEach(() => { trail = new DataTrail({}); locationService.push(preTrailUrl); activateFullSceneTree(trail); getOtelResourcesVar(trail).setState({ filters: [{ key: 'service_name', operator: '=', value: 'adservice' }] }); getOtelDepEnvVar(trail).changeValueTo('production'); + getOtelGroupLeftVar(trail).setState({ value: 'attribute1,attribute2' }); }); it('should start with hidden dep env variable', () => { @@ -546,5 +556,9 @@ describe('DataTrail', () => { it('should add history step for when updating the dep env variable', () => { expect(trail.state.history.state.steps[3].type).toBe('dep_env'); }); + + it('should have a group left variable for resource attributes', () => { + expect(getOtelGroupLeftVar(trail).state.value).toBe('attribute1,attribute2'); + }); }); }); diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 6af2c2405a9..65c72aef90b 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -46,7 +46,7 @@ import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; import { reportChangeInLabelFilters } from './interactions'; import { getDeploymentEnvironments, TARGET_INFO_FILTER, totalOtelResources } from './otel/api'; import { OtelResourcesObject, OtelTargetType } from './otel/types'; -import { sortResources, getOtelJoinQuery, getOtelResourcesObject } from './otel/util'; +import { sortResources, getOtelJoinQuery, getOtelResourcesObject, updateOtelJoinWithGroupLeft } from './otel/util'; import { getVariablesWithOtelJoinQueryConstant, MetricSelectedEvent, @@ -55,6 +55,7 @@ import { VAR_DATASOURCE_EXPR, VAR_FILTERS, VAR_OTEL_DEPLOYMENT_ENV, + VAR_OTEL_GROUP_LEFT, VAR_OTEL_JOIN_QUERY, VAR_OTEL_RESOURCES, } from './shared'; @@ -247,8 +248,14 @@ export class DataTrail extends SceneObjectBase { locationService.replace(fullUrl); } - private _handleMetricSelectedEvent(evt: MetricSelectedEvent) { - this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload)); + private async _handleMetricSelectedEvent(evt: MetricSelectedEvent) { + const metric = evt.payload ?? ''; + + if (this.state.useOtelExperience) { + await updateOtelJoinWithGroupLeft(this, metric); + } + + this.setState(this.getSceneUpdatesForNewMetricValue(metric)); // Add metric to adhoc filters baseFilter const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this); @@ -674,6 +681,11 @@ function getVariableSet( supportsMultiValueOperators: true, }), ...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''), + new ConstantVariable({ + name: VAR_OTEL_GROUP_LEFT, + value: undefined, + hide: VariableHide.hideVariable, + }), ], }); } diff --git a/public/app/features/trails/interactions.ts b/public/app/features/trails/interactions.ts index c9bb69fca15..937bc9302e2 100644 --- a/public/app/features/trails/interactions.ts +++ b/public/app/features/trails/interactions.ts @@ -18,6 +18,7 @@ type Interactions = { // By clicking on the label selector at the top of the breakdown | 'selector' ); + otel_resource_attribute?: boolean; }; // User changed a label filter. label_filter_changed: { diff --git a/public/app/features/trails/otel/api.test.ts b/public/app/features/trails/otel/api.test.ts index d0189b79c2b..f1033344033 100644 --- a/public/app/features/trails/otel/api.test.ts +++ b/public/app/features/trails/otel/api.test.ts @@ -1,9 +1,18 @@ import { RawTimeRange } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; -import { getOtelResources, totalOtelResources, isOtelStandardization, getDeploymentEnvironments } from './api'; +import { + getOtelResources, + totalOtelResources, + isOtelStandardization, + getDeploymentEnvironments, + getFilteredResourceAttributes, +} from './api'; jest.mock('@grafana/runtime', () => ({ + config: { + publicDashboardAccessToken: '123', + }, getBackendSrv: () => { return { get: ( @@ -12,9 +21,13 @@ jest.mock('@grafana/runtime', () => ({ requestId?: string, options?: Partial ) => { + // explore-metrics-otel-resources if (requestId === 'explore-metrics-otel-resources') { return Promise.resolve({ data: ['job', 'instance', 'deployment_environment'] }); - } else if (requestId === 'explore-metrics-otel-check-total') { + } else if ( + requestId === 'explore-metrics-otel-check-total-count(target_info{}) by (job, instance)' || + requestId === 'explore-metrics-otel-check-total-count(metric) by (job, instance)' + ) { return Promise.resolve({ data: { result: [ @@ -31,6 +44,18 @@ jest.mock('@grafana/runtime', () => ({ }); } else if (requestId === 'explore-metrics-otel-resources-deployment-env') { return Promise.resolve({ data: ['env1', 'env2'] }); + } else if ( + requestId === + 'explore-metrics-otel-resources-metric-job-instance-metric{job=~"job1|job2",instance=~"instance1|instance2"}' + ) { + // part of getFilteredResourceAttributes to get metric labels. We prioritize metric labels over resource attributes so we use these to filter + return Promise.resolve({ data: ['promotedResourceAttribute'] }); + } else if ( + requestId === + 'explore-metrics-otel-resources-metric-job-instance-target_info{job=~"job1|job2",instance=~"instance1|instance2"}' + ) { + // part of getFilteredResourceAttributes to get instance labels + return Promise.resolve({ data: ['promotedResourceAttribute', 'resourceAttribute'] }); } return []; }, @@ -87,4 +112,14 @@ describe('OTEL API', () => { expect(environments).toEqual(['env1', 'env2']); }); }); + + describe('getFilteredResourceAttributes', () => { + it('should fetch and filter OTEL resources with excluded filters', async () => { + const resources = await getFilteredResourceAttributes(dataSourceUid, timeRange, 'metric', ['job']); + // promotedResourceAttribute will be filtered out because even though it is a resource attribute, it is also a metric label and wee prioritize metric labels + expect(resources).not.toEqual(['promotedResourceAttribute', 'resourceAttribute']); + // the resource attributes returned are the ones only present on target_info + expect(resources).toEqual(['resourceAttribute']); + }); + }); }); diff --git a/public/app/features/trails/otel/api.ts b/public/app/features/trails/otel/api.ts index f0e4ca98fe1..0e763d98187 100644 --- a/public/app/features/trails/otel/api.ts +++ b/public/app/features/trails/otel/api.ts @@ -3,6 +3,7 @@ import { getPrometheusTime } from '@grafana/prometheus/src/language_utils'; import { getBackendSrv } from '@grafana/runtime'; import { OtelResponse, LabelResponse, OtelTargetType } from './types'; +import { sortResources } from './util'; const OTEL_RESOURCE_EXCLUDED_FILTERS = ['__name__', 'deployment_environment']; // name is handled by metric search metrics bar /** @@ -10,6 +11,7 @@ const OTEL_RESOURCE_EXCLUDED_FILTERS = ['__name__', 'deployment_environment']; / * When filters are added, we can also get a list of otel targets used to reduce the metric list * */ const otelTargetInfoQuery = (filters?: string) => `count(target_info{${filters ?? ''}}) by (job, instance)`; +const metricOtelJobInstanceQuery = (metric: string) => `count(${metric}) by (job, instance)`; export const TARGET_INFO_FILTER = { key: '__name__', value: 'target_info', operator: '=' }; @@ -18,6 +20,8 @@ export const TARGET_INFO_FILTER = { key: '__name__', value: 'target_info', opera * Parse the results to get label filters. * @param dataSourceUid * @param timeRange + * @param excludedFilters + * @param matchFilters * @returns OtelResourcesType[], labels for the query result requesting matching job and instance on target_info metric */ export async function getOtelResources( @@ -47,7 +51,8 @@ export async function getOtelResources( } /** - * Get the total amount of job/instance pairs on target info metric + * Get the total amount of job/instance pairs on a metric. + * Can be used for target_info. * * @param dataSourceUid * @param timeRange @@ -57,22 +62,25 @@ export async function getOtelResources( export async function totalOtelResources( dataSourceUid: string, timeRange: RawTimeRange, - filters?: string + filters?: string, + metric?: string ): Promise { const start = getPrometheusTime(timeRange.from, false); const end = getPrometheusTime(timeRange.to, true); + const query = metric ? metricOtelJobInstanceQuery(metric) : otelTargetInfoQuery(filters); + const url = `/api/datasources/uid/${dataSourceUid}/resources/api/v1/query`; const paramsTotalTargets: Record = { start, end, - query: otelTargetInfoQuery(filters), + query, }; const responseTotal = await getBackendSrv().get( url, paramsTotalTargets, - 'explore-metrics-otel-check-total' + `explore-metrics-otel-check-total-${query}` ); let jobs: string[] = []; @@ -164,3 +172,92 @@ export async function getDeploymentEnvironments(dataSourceUid: string, timeRange return resources; } + +/** + * For OTel, get the resource attributes for a metric. + * Handle filtering on both OTel resources as well as metric labels. + * + * @param datasourceUid + * @param timeRange + * @param metric + * @param excludedFilters + * @returns + */ +export async function getFilteredResourceAttributes( + datasourceUid: string, + timeRange: RawTimeRange, + metric: string, + excludedFilters?: string[] +) { + // These filters should not be included in the resource attributes for users to choose from + const allExcludedFilters = (excludedFilters ?? []).concat(OTEL_RESOURCE_EXCLUDED_FILTERS); + + // The jobs and instances for the metric + const metricResources = await totalOtelResources(datasourceUid, timeRange, undefined, metric); + + // OTel metrics require unique identifies for the resource. Job+instance is the unique identifier. + // If there are none, we cannot join on a target_info resource + if (metricResources.jobs.length === 0 || metricResources.instances.length === 0) { + return []; + } + + // The URL for the labels endpoint + const url = `/api/datasources/uid/${datasourceUid}/resources/api/v1/labels`; + + // The match param for the metric to get all possible labels for this metric + const metricMatchParam = `${metric}{job=~"${metricResources.jobs.join('|')}",instance=~"${metricResources.instances.join('|')}"}`; + + const start = getPrometheusTime(timeRange.from, false); + const end = getPrometheusTime(timeRange.to, true); + + const metricParams: Record = { + start, + end, + 'match[]': metricMatchParam, + }; + + // We prioritize metric attributes over resource attributes. + // If a label is present in both metric and target_info, we exclude it from the resource attributes. + // This prevents errors in the join query. + const metricResponse = await getBackendSrv().get( + url, + metricParams, + `explore-metrics-otel-resources-metric-job-instance-${metricMatchParam}` + ); + // the metric labels here + const metricLabels = metricResponse.data ?? []; + + // only get the resource attributes filtered by job and instance values present on the metric + const targetInfoMatchParam = `target_info{job=~"${metricResources.jobs.join('|')}",instance=~"${metricResources.instances.join('|')}"}`; + + const targetInfoParams: Record = { + start, + end, + 'match[]': targetInfoMatchParam, + }; + + // these are the resource attributes that come from target_info, + // filtered by the metric job and instance + const targetInfoResponse = await getBackendSrv().get( + url, + targetInfoParams, + `explore-metrics-otel-resources-metric-job-instance-${targetInfoMatchParam}` + ); + + const targetInfoAttributes = targetInfoResponse.data ?? []; + + // first filters out metric labels from the resource attributes + const firstFilter = targetInfoAttributes.filter((resource) => !metricLabels.includes(resource)); + + // exclude __name__ or deployment_environment or previously chosen filters + const secondFilter = firstFilter + .filter((resource) => !allExcludedFilters.includes(resource)) + .map((el) => ({ text: el })); + + // sort the resources, surfacing the blessedlist on top + let sortedResourceAttributes = sortResources(secondFilter, ['job']); + // return a string array + const resourceAttributes = sortedResourceAttributes.map((el) => el.text); + + return resourceAttributes; +} diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index 0734355aebf..50cf26daf6d 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -1,8 +1,17 @@ import { MetricFindValue } from '@grafana/data'; -import { AdHocFiltersVariable, CustomVariable, sceneGraph, SceneObject } from '@grafana/scenes'; +import { AdHocFiltersVariable, ConstantVariable, CustomVariable, sceneGraph, SceneObject } from '@grafana/scenes'; -import { VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_RESOURCES } from '../shared'; +import { DataTrail } from '../DataTrail'; +import { + VAR_DATASOURCE_EXPR, + VAR_FILTERS, + VAR_OTEL_DEPLOYMENT_ENV, + VAR_OTEL_GROUP_LEFT, + VAR_OTEL_JOIN_QUERY, + VAR_OTEL_RESOURCES, +} from '../shared'; +import { getFilteredResourceAttributes } from './api'; import { OtelResourcesObject } from './types'; export const blessedList = (): Record => { @@ -60,11 +69,19 @@ export function sortResources(resources: MetricFindValue[], excluded: string[]) * @param otelResourcesObject * @returns a string that is used to add a join query to filter otel resources */ -export function getOtelJoinQuery(otelResourcesObject: OtelResourcesObject): string { +export function getOtelJoinQuery(otelResourcesObject: OtelResourcesObject, scene?: SceneObject): string { + // the group left is for when a user wants to breakdown by a resource attribute + let groupLeft = ''; + + if (scene) { + const value = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, scene)?.getValue(); + groupLeft = typeof value === 'string' ? value : ''; + } + let otelResourcesJoinQuery = ''; if (otelResourcesObject.filters && otelResourcesObject.labels) { // add support for otel data sources that are not standardized, i.e., have non unique target_info series by job, instance - otelResourcesJoinQuery = `* on (job, instance) group_left(${otelResourcesObject.labels}) topk by (job, instance) (1, target_info{${otelResourcesObject.filters}})`; + otelResourcesJoinQuery = `* on (job, instance) group_left(${groupLeft}) topk by (job, instance) (1, target_info{${otelResourcesObject.filters}})`; } return otelResourcesJoinQuery; @@ -193,3 +210,78 @@ export function limitOtelMatchTerms( instancesRegex, }; } + +/** + * This updates the OTel join query variable that is interpolated into all queries. + * When a user is in the breakdown or overview tab, they may want to breakdown a metric by a resource attribute. + * The only way to do this is by enriching the metric with the target_info resource. + * This is done by joining on a unique identifier for the resource, job and instance. + * The we can get the resource attributes for the metric, enrich the metric with the join query and + * show panels by aggregate functions over attributes. + * E.g. sum(metric * on (job, instance) group_left(cloud_region) topk by (job, instance) (1, target_info{})) by cloud_region + * where cloud_region is a resource attribute but not on the metric. + * BUT if the attribute is on the metric already, we shouldn't add it to the group left. + * + * @param trail + * @param metric + * @returns + */ +export async function updateOtelJoinWithGroupLeft(trail: DataTrail, metric: string) { + // When to remove or add the group left + // REMOVE + // - selecting a new metric and returning to metric select scene + // ADD + // - the metric is selected from previews + // - the metric is loaded from refresh in metric scene + // - the metric is loaded from bookmark + const timeRange = trail.state.$timeRange?.state; + if (!timeRange) { + return; + } + const otelGroupLeft = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); + const otelJoinQueryVariable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, trail); + if (!(otelGroupLeft instanceof ConstantVariable) || !(otelJoinQueryVariable instanceof ConstantVariable)) { + return; + } + // Remove the group left + if (!metric) { + // if the metric is not present, that means we are in the metric select scene + // and that should have no group left because it may interfere with queries. + otelGroupLeft.setState({ value: '' }); + const resourceObject = getOtelResourcesObject(trail); + const otelJoinQuery = getOtelJoinQuery(resourceObject, trail); + otelJoinQueryVariable.setState({ value: otelJoinQuery }); + return; + } + // if the metric is target_info, it already has all resource attributes + if (metric === 'target_info') { + return; + } + + // Add the group left + const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, trail); + const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail); + let excludeFilterKeys: string[] = []; + if (filtersVariable instanceof AdHocFiltersVariable && otelResourcesVariable instanceof AdHocFiltersVariable) { + // do not include the following + // 1. pre selected label filters + // 2. pre selected otel resource attribute filters + // 3. job and instance labels (will break the join) + const filterKeys = filtersVariable.state.filters.map((f) => f.key); + const otelKeys = otelResourcesVariable.state.filters.map((f) => f.key); + excludeFilterKeys = filterKeys.concat(otelKeys); + excludeFilterKeys = excludeFilterKeys.concat(['job', 'instance']); + } + const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); + const attributes = await getFilteredResourceAttributes(datasourceUid, timeRange, metric, excludeFilterKeys); + // here we start to add the attributes to the group left + if (attributes.length > 0) { + // update the group left variable that contains all the filtered resource attributes + otelGroupLeft.setState({ value: attributes.join(',') }); + // get the new otel join query that includes the group left attributes + const resourceObject = getOtelResourcesObject(trail); + const otelJoinQuery = getOtelJoinQuery(resourceObject, trail); + // update the join query that is interpolated in all queries + otelJoinQueryVariable.setState({ value: otelJoinQuery }); + } +} diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts index 39fbf12af7a..eaa54133afd 100644 --- a/public/app/features/trails/otel/utils.test.ts +++ b/public/app/features/trails/otel/utils.test.ts @@ -1,6 +1,21 @@ import { MetricFindValue } from '@grafana/data'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { AdHocFiltersVariable, ConstantVariable, CustomVariable, sceneGraph } from '@grafana/scenes'; +import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; +import { activateFullSceneTree } from 'app/features/dashboard-scene/utils/test-utils'; -import { sortResources, getOtelJoinQuery, blessedList, limitOtelMatchTerms } from './util'; +import { DataTrail } from '../DataTrail'; +import { VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_GROUP_LEFT, VAR_OTEL_JOIN_QUERY, VAR_OTEL_RESOURCES } from '../shared'; + +import { sortResources, getOtelJoinQuery, blessedList, limitOtelMatchTerms, updateOtelJoinWithGroupLeft } from './util'; + +jest.mock('./api', () => ({ + totalOtelResources: jest.fn(() => ({ job: 'oteldemo', instance: 'instance' })), + getDeploymentEnvironments: jest.fn(() => ['production', 'staging']), + isOtelStandardization: jest.fn(() => true), + getFilteredResourceAttributes: jest.fn().mockResolvedValue(['resourceAttribute']), +})); describe('sortResources', () => { it('should sort and filter resources correctly', () => { @@ -26,7 +41,7 @@ describe('getOtelJoinQuery', () => { const result = getOtelJoinQuery(otelResourcesObject); expect(result).toBe( - '* on (job, instance) group_left(deployment_environment,custom_label) topk by (job, instance) (1, target_info{job="test-job",instance="test-instance"})' + '* on (job, instance) group_left() topk by (job, instance) (1, target_info{job="test-job",instance="test-instance"})' ); }); @@ -102,7 +117,7 @@ describe('getOtelJoinQuery', () => { const result = getOtelJoinQuery(otelResourcesObject); expect(result).toBe( - '* on (job, instance) group_left(deployment_environment,custom_label) topk by (job, instance) (1, target_info{job="test-job",instance="test-instance"})' + '* on (job, instance) group_left() topk by (job, instance) (1, target_info{job="test-job",instance="test-instance"})' ); }); @@ -189,3 +204,75 @@ describe('limitOtelMatchTerms', () => { expect(result.instancesRegex).toEqual('instance=~"instance1|instance2|instance3|instance4|instance5"'); }); }); + +describe('updateOtelJoinWithGroupLeft', () => { + let trail: DataTrail; + const preTrailUrl = + '/trail?from=now-1h&to=now&var-ds=edwxqcebl0cg0c&var-deployment_environment=oteldemo01&var-otel_resources=k8s_cluster_name%7C%3D%7Cappo11ydev01&var-filters=&refresh=&metricPrefix=all&metricSearch=http&actionView=breakdown&var-groupby=$__all&metric=http_client_duration_milliseconds_bucket'; + + function getOtelDepEnvVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_OTEL_DEPLOYMENT_ENV, trail); + if (variable instanceof CustomVariable) { + return variable; + } + throw new Error('getDepEnvVar failed'); + } + + function getOtelJoinQueryVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, trail); + if (variable instanceof ConstantVariable) { + return variable; + } + throw new Error('getDepEnvVar failed'); + } + + function getOtelResourcesVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, trail); + if (variable instanceof AdHocFiltersVariable) { + return variable; + } + throw new Error('getOtelResourcesVar failed'); + } + + function getOtelGroupLeftVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail); + if (variable instanceof ConstantVariable) { + return variable; + } + throw new Error('getOtelResourcesVar failed'); + } + + beforeEach(() => { + jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); + setDataSourceSrv( + new MockDataSourceSrv({ + prom: mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + }), + }) + ); + trail = new DataTrail({}); + locationService.push(preTrailUrl); + activateFullSceneTree(trail); + getOtelResourcesVar(trail).setState({ filters: [{ key: 'service_name', operator: '=', value: 'adservice' }] }); + getOtelDepEnvVar(trail).changeValueTo('production'); + getOtelGroupLeftVar(trail).setState({ value: 'attribute1,attribute2' }); + }); + + it('should update OTel join query with the group left resource attributes', async () => { + await updateOtelJoinWithGroupLeft(trail, 'metric'); + const otelJoinQueryVar = getOtelJoinQueryVar(trail); + // this will include the group left resource attributes + expect(otelJoinQueryVar.getValue()).toBe( + '* on (job, instance) group_left(resourceAttribute) topk by (job, instance) (1, target_info{deployment_environment="production",service_name="adservice"})' + ); + }); + + it('should not update OTel join query with the group left resource attributes when the metric is target_info', async () => { + await updateOtelJoinWithGroupLeft(trail, 'target_info'); + const otelJoinQueryVar = getOtelJoinQueryVar(trail); + + expect(otelJoinQueryVar.getValue()).toBe(''); + }); +}); diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 9bad5b0b449..013c72817c3 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -29,6 +29,10 @@ export const VAR_OTEL_DEPLOYMENT_ENV = 'deployment_environment'; export const VAR_OTEL_DEPLOYMENT_ENV_EXPR = '${deployment_environment}'; export const VAR_OTEL_JOIN_QUERY = 'otel_join_query'; export const VAR_OTEL_JOIN_QUERY_EXPR = '${otel_join_query}'; +export const VAR_OTEL_GROUP_BY = 'otel_groupby'; +export const VAR_OTEL_GROUP_BY_EXPR = '${otel_groupby}'; +export const VAR_OTEL_GROUP_LEFT = 'otel_group_left'; +export const VAR_OTEL_GROUP_LEFT_EXPR = '${otel_group_left}'; export const LOGS_METRIC = '$__logs__'; export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query'; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 28c3e75f798..43faff4dc65 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2900,7 +2900,7 @@ "metric-overview": { "description-label": "Description", "labels": "Labels", - "metric-attributes": "Metric attributes", + "metric-attributes": "Attributes", "no-description": "No description available", "type-label": "Type", "unit-label": "Unit", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index a2b8a080fd9..d1ac1ff091a 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2900,7 +2900,7 @@ "metric-overview": { "description-label": "Đęşčřįpŧįőʼn", "labels": "Ŀäþęľş", - "metric-attributes": "Męŧřįč äŧŧřįþūŧęş", + "metric-attributes": "Åŧŧřįþūŧęş", "no-description": "Ńő đęşčřįpŧįőʼn äväįľäþľę", "type-label": "Ŧypę", "unit-label": "Ůʼnįŧ",