Explore metrics: Add OTel resource attributes in overview and breakdown scenes (#95009)

* add new otel grouping variables

* add call for resource attributes for a metric

* add function to update variables for otel join

* interpolate metric in match param

* update group left when starting trail

* process the group left before setting the metric and showing the metric scene

* add attributes to metric overview list

* change label name to attributes because it contains resource attributes and metric attributes

* add resource attributes to label breakdown select

* add otel resource attribute to filters from label breakdown

* add otel flag for rudderstack event when breakdown label selected

* for translations

* add test for new variable in datatrail spec

* add test for filtering otel resource attributes

* update documentation

* add tests for updating the join query with group left resource attributes

* use Nick and Ismail's suggestions, return early, space and no type needed for timerange

* remove unused import
This commit is contained in:
Brendan O'Handley 2024-10-30 14:29:08 -05:00 committed by GitHub
parent 2b0a439ad3
commit d2d7ae2e86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 526 additions and 33 deletions

View File

@ -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<MetricOverviewSceneStat
private onReferencedVariableValueChanged() {
this.updateMetadata();
this.updateOtelGroupLeft();
}
private async updateMetadata() {
@ -63,12 +68,41 @@ export class MetricOverviewScene extends SceneObjectBase<MetricOverviewSceneStat
this.setState({ metadata, metadataLoading: false });
}
private async updateOtelGroupLeft() {
const trail = getTrailFor(this);
if (trail.state.useOtelExperience) {
await updateOtelJoinWithGroupLeft(trail, trail.state.metric ?? '');
}
}
public static Component = ({ model }: SceneComponentProps<MetricOverviewScene>) => {
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<MetricOverviewSceneStat
<Stack direction="column" gap={0.5}>
<Text weight={'medium'}>
{useOtelExperience ? (
<Trans i18nKey="trails.metric-overview.metric-attributes">Metric attributes</Trans>
<Trans i18nKey="trails.metric-overview.metric-attributes">Attributes</Trans>
) : (
<Trans i18nKey="trails.metric-overview.labels">Labels</Trans>
)}
</Text>
{labelOptions.length === 0 && 'Unable to fetch labels.'}
{labelOptions.map((l) => (
{allLabelOptions.length === 0 && 'Unable to fetch labels.'}
{allLabelOptions.map((l) => (
<TextLink
key={l.label}
href={`#View breakdown for ${l.label}`}

View File

@ -9,6 +9,7 @@ import {
import { Button } from '@grafana/ui';
import { reportExploreMetrics } from '../interactions';
import { VAR_OTEL_GROUP_LEFT, VAR_OTEL_RESOURCES } from '../shared';
import { getTrailFor } from '../utils';
export interface AddToFiltersGraphActionState extends SceneObjectState {
@ -30,12 +31,31 @@ export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphAc
const labelName = Object.keys(labels)[0];
reportExploreMetrics('label_filter_changed', { label: labelName, action: 'added', cause: 'breakdown' });
const trail = getTrailFor(this);
const resourceAttributes = sceneGraph.lookupVariable(VAR_OTEL_GROUP_LEFT, trail);
const allAttributes = resourceAttributes?.getValue();
const filter = {
key: labelName,
operator: '=',
value: labels[labelName],
};
trail.addFilterWithoutReportingInteraction(filter);
// add to either label filters or otel resource filters
if (
allAttributes &&
typeof allAttributes === 'string' &&
// if the label chosen is a resource attribute, add it to the otel resource variable
allAttributes?.split(',').includes(labelName)
) {
// add to OTel resource var filters
const otelResourcesVar = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, trail);
if (!(otelResourcesVar instanceof AdHocFiltersVariable)) {
return;
}
otelResourcesVar.setState({ filters: [...variable.state.filters, filter] });
} else {
// add to regular var filters
trail.addFilterWithoutReportingInteraction(filter);
}
};
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {

View File

@ -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<LabelBreakdownSceneStat
this.clearBreakdownPanelAxisValues();
});
// OTEL
this._subs.add(
trail.subscribeToState(({ useOtelExperience }, oldState) => {
// 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<LabelBreakdownSceneStat
private updateBody(variable: QueryVariable) {
const options = getLabelOptions(this, variable);
const trail = getTrailFor(this);
let allLabelOptions = options;
if (trail.state.useOtelExperience) {
allLabelOptions = this.updateLabelOptions(trail, allLabelOptions);
}
const stateUpdate: Partial<LabelBreakdownSceneState> = {
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<LabelBreakdownSceneStat
variable.changeValueTo(value);
};
private async updateOtelGroupLeft() {
const trail = getTrailFor(this);
if (trail.state.useOtelExperience) {
await updateOtelJoinWithGroupLeft(trail, trail.state.metric ?? '');
}
}
/**
* supplement normal label options with resource attributes
* @param trail
* @param allLabelOptions
* @returns
*/
private updateLabelOptions(trail: DataTrail, allLabelOptions: SelectableValue[]): Array<SelectableValue<string>> {
// 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<LabelBreakdownScene>) => {
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 (
<div className={styles.container}>
<StatusWrapper {...{ isLoading: loading, blockingMessage }}>
<div className={styles.controls}>
{!loading && Boolean(labels.length) && (
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
{!loading && labels.length && (
<Field label={useOtelExperience ? 'By attribute' : 'By label'}>
<BreakdownLabelSelector options={allLabelOptions} value={value} onChange={model.onChange} />
</Field>
)}
@ -282,7 +369,8 @@ function getStyles(theme: GrafanaTheme2) {
export function buildAllLayout(
options: Array<SelectableValue<string>>,
queryDef: AutoQueryDef,
onBreakdownLayoutChange: BreakdownLayoutChangeCallback
onBreakdownLayoutChange: BreakdownLayoutChangeCallback,
useOtelExperience?: boolean
) {
const children: SceneFlexItemLike[] = [];
@ -460,7 +548,16 @@ interface SelectLabelActionState extends SceneObjectState {
export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> {
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);
};

View File

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

View File

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

View File

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

View File

@ -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<BackendSrvRequest>
) => {
// 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']);
});
});
});

View File

@ -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<OtelTargetType> {
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<string, string | number> = {
start,
end,
query: otelTargetInfoQuery(filters),
query,
};
const responseTotal = await getBackendSrv().get<OtelResponse>(
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<string, string | number> = {
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<LabelResponse>(
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<string, string | number> = {
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<LabelResponse>(
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;
}

View File

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

View File

@ -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('');
});
});

View File

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

View File

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

View File

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