mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
2b0a439ad3
commit
d2d7ae2e86
@ -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}`}
|
||||
|
@ -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],
|
||||
};
|
||||
|
||||
// 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>) => {
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
@ -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';
|
||||
|
@ -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",
|
||||
|
@ -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įŧ",
|
||||
|
Loading…
Reference in New Issue
Block a user