Explore Metrics: Get OTel resources and filter metrics and labels (#91221)

* add OTel filter in metric select scene

* add resource query to get matching OTEL job&instance

* filter metrics by OTEL resources

* only add otel select if DS has OTEL matching job and instance

* add folder for otel resources

* upate metric select for new otel folder

* move otel api call

* get otel resources for labels for single series job/instance target_info

* add otel resources to adhoc variable dropdown

* update otel api to check for standardization and return labels

* label types for api

* check standardization, show otel variable, select depenv, update other variables

* remove otel target list from metric select scene

* load resources if dep_env label has already been selected

* exclude previously used filters

* do not check standardization if there are already otel filters

* drop filters when switching data sources

* add experience var for switching to otel experience

* remove otel from variables and place near settings

* add error for non-standard prom with otel resources

* fix typescript errors, remove ts-ignores

* add custom variable for deployment environment like app-olly

* fix name of otel variable

* add function for getting otel resources from variables

* add otel join query const

* update standard check to be simpler

* allow for unstandard otel data sources but give warning

* add otelJoinQuery to the base query and clean up variables when state changes

* refactor otel functions to return filters for targets, use targets to filter metrics

* update metric names on otel target filter change

* when no otel targets for otel resource filter, show no metrics

* move switch to settings, default to use experience, refactor otel checks

* clean code

* fix refactor to add hasOtelResources for showing the switch in settings

* sort otel resources by blessed list

* reset otel when data source is changed

* move otel experience toggle back outside settings

* move showPreviews into settings

* do not re-add otel resources from blessed list to filters when already selected

* add otel join query variable to histogram base query

* only show settings for appropriate scenes

* show info tooltip the same but show error on hover for disabling otel exp for unstandard DS

* refactor tagKeys and tagValues for otel resources variable, fix promoted list ordering, fix dep env state bug

* default dep env value

* apply var filters only where they are using VAR_FILTER_EXPR in queryies

* change copy for labels to attributes

* do not group_left job label when already joining by job

* update copy for label variable when using otel

* remove isStandard check for now because of data staleness in Prometheus

* default to showing heatmap for histograms

* add trail history for selecting dep env and otel resources

* add otel resource attributes tests for DataTrail

* move otel functions to utils

* write tests for otel api calls

* write tests for otel utils functions

* fix history

* standard otel has target_info metric and deployment_environment resource attributes

* fix tests

* refactor otel functions for updating state and variables

* clean code

* fix tests

* fix tests

* mock checkDataSourceForOtelResources

* fix tests

* update query tests with otelJoinQuery and default to heatmap for _bucket metrics

* fix tests for otel api

* fix trail history test

* fix trail store tests for missing otel variables

* make i18n-extract

* handle target_info with inconsistent job and instance labels

* fix otel copy and <Trans> component

* fix custom variable deployment environment bug when switchiing data sources from non otel to otel

* fix linting error for trans component

* format i18nKey correctly

* clean up old comments

* add frontend hardening for OTel job and instance metric list filtering

* fix test for deployment environment custom variable to use changeValueTo

* fix i18n

* remove comments for fixed bug

* edit skipped tests
This commit is contained in:
Brendan O'Handley 2024-09-19 09:34:31 -05:00 committed by GitHub
parent 542105b680
commit 4d1adf9db4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1572 additions and 113 deletions

View File

@ -5279,8 +5279,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/trails/DataTrailSettings.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/trails/DataTrailsHistory.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]

View File

@ -211,13 +211,15 @@ export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
const { labels, body, loading, value, blockingMessage } = model.useState();
const styles = useStyles2(getStyles);
const { useOtelExperience } = getTrailFor(model).useState();
return (
<div className={styles.container}>
<StatusWrapper {...{ isLoading: loading, blockingMessage }}>
<div className={styles.controls}>
{!loading && labels.length && (
<div className={styles.controlsLeft}>
<Field label="By label">
<Field label={useOtelExperience ? 'By metric attribute' : 'By label'}>
<BreakdownLabelSelector options={labels} value={value} onChange={model.onChange} />
</Field>
</div>

View File

@ -68,6 +68,8 @@ export class MetricOverviewScene extends SceneObjectBase<MetricOverviewSceneStat
const variable = model.getVariable();
const { loading: labelsLoading, options: labelOptions } = variable.useState();
const { useOtelExperience } = getTrailFor(model).useState();
// Get unit name from the metric name
const metricScene = getMetricSceneFor(model);
const metric = metricScene.state.metric;
@ -110,7 +112,11 @@ export class MetricOverviewScene extends SceneObjectBase<MetricOverviewSceneStat
</Stack>
<Stack direction="column" gap={0.5}>
<Text weight={'medium'}>
<Trans i18nKey="trails.metric-overview.labels-label">Labels</Trans>
{useOtelExperience ? (
<Trans i18nKey="trails.metric-overview.metric-attributes">Metric attributes</Trans>
) : (
<Trans i18nKey="trails.metric-overview.labels">Labels</Trans>
)}
</Text>
{labelOptions.length === 0 && 'Unable to fetch labels.'}
{labelOptions.map((l) => (

View File

@ -4,6 +4,8 @@ function expandExpr(shortenedExpr: string) {
return shortenedExpr.replace('...', '${metric}{${filters}}');
}
const otelJoinQuery = '${otel_join_query}';
describe('getAutoQueriesForMetric', () => {
describe('for the summary/histogram types', () => {
const etc = '{${filters}}[$__rate_interval]';
@ -14,19 +16,20 @@ describe('getAutoQueriesForMetric', () => {
test('main query is the mean', () => {
const [{ expr }] = result.main.queries;
const mean = `sum(rate(SUM_OR_HIST_sum${etc}))/sum(rate(SUM_OR_HIST_count${etc}))`;
const mean = `sum(rate(SUM_OR_HIST_sum${etc}) ${otelJoinQuery})/sum(rate(SUM_OR_HIST_count${etc}) ${otelJoinQuery})`;
expect(expr).toBe(mean);
});
test('preview query is the mean', () => {
const [{ expr }] = result.preview.queries;
const mean = `sum(rate(SUM_OR_HIST_sum${etc}))/sum(rate(SUM_OR_HIST_count${etc}))`;
const mean = `sum(rate(SUM_OR_HIST_sum${etc}) ${otelJoinQuery})/sum(rate(SUM_OR_HIST_count${etc}) ${otelJoinQuery})`;
expect(expr).toBe(mean);
});
test('breakdown query is the mean by group', () => {
const [{ expr }] = result.breakdown.queries;
const meanBreakdown = `sum(rate(SUM_OR_HIST_sum${etc}))${byGroup}/sum(rate(SUM_OR_HIST_count${etc}))${byGroup}`;
const meanBreakdown = `sum(rate(SUM_OR_HIST_sum${etc}) ${otelJoinQuery})${byGroup}/sum(rate(SUM_OR_HIST_count${etc}) ${otelJoinQuery})${byGroup}`;
expect(expr).toBe(meanBreakdown);
});
@ -40,19 +43,19 @@ describe('getAutoQueriesForMetric', () => {
test('main query is an overall rate', () => {
const [{ expr }] = result.main.queries;
const overallRate = `sum(rate(\${metric}${etc}))`;
const overallRate = `sum(rate(\${metric}${etc}) ${otelJoinQuery})`;
expect(expr).toBe(overallRate);
});
test('preview query is an overall rate', () => {
const [{ expr }] = result.preview.queries;
const overallRate = `sum(rate(\${metric}${etc}))`;
const overallRate = `sum(rate(\${metric}${etc}) ${otelJoinQuery})`;
expect(expr).toBe(overallRate);
});
test('breakdown query is an overall rate by group', () => {
const [{ expr }] = result.breakdown.queries;
const overallRateBreakdown = `sum(rate(\${metric}${etc}))${byGroup}`;
const overallRateBreakdown = `sum(rate(\${metric}${etc}) ${otelJoinQuery})${byGroup}`;
expect(expr).toBe(overallRateBreakdown);
});
@ -61,6 +64,7 @@ describe('getAutoQueriesForMetric', () => {
});
});
// ***WE DEFAULT TO HEATMAP HERE
describe('metrics with _bucket suffix', () => {
const result = getAutoQueriesForMetric('HIST_bucket');
@ -99,9 +103,10 @@ describe('getAutoQueriesForMetric', () => {
});
});
test('preview panel has 50th percentile query', () => {
test('preview panel has heatmap query', () => {
const [{ expr }] = result.preview.queries;
expect(expr).toBe(percentileQueries.get(50));
const expected = 'sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query})';
expect(expr).toBe(expected);
});
const percentileGroupedQueries = new Map<number, string>();
@ -130,34 +135,35 @@ describe('getAutoQueriesForMetric', () => {
describe('Consider result.main query (only first)', () => {
it.each([
// no rate
['PREFIX_general', 'avg(...)', 'short', 1],
['PREFIX_bytes', 'avg(...)', 'bytes', 1],
['PREFIX_seconds', 'avg(...)', 's', 1],
['PREFIX_general', 'avg(... ${otel_join_query})', 'short', 1],
['PREFIX_bytes', 'avg(... ${otel_join_query})', 'bytes', 1],
['PREFIX_seconds', 'avg(... ${otel_join_query})', 's', 1],
// rate with counts per second
['PREFIX_count', 'sum(rate(...[$__rate_interval]))', 'cps', 1], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]))', 'cps', 1],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))', 'cps', 1],
['PREFIX_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps', 1], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps', 1],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps', 1],
// rate with seconds per second
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))', 'short', 1], // s/s
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'short', 1], // s/s
// rate with bytes per second
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))', 'Bps', 1], // bytes/s
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'Bps', 1], // bytes/s
// mean with non-rated units
[
'PREFIX_seconds_sum',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]) ${otel_join_query})/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]) ${otel_join_query})',
's',
1,
],
[
'PREFIX_bytes_sum',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]) ${otel_join_query})/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]) ${otel_join_query})',
'bytes',
1,
],
// ***WE DEFAULT TO HEATMAP HERE
// Bucket
['PREFIX_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 'short', 3],
['PREFIX_seconds_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 's', 3],
['PREFIX_bytes_bucket', 'histogram_quantile(0.99, sum by(le) (rate(...[$__rate_interval])))', 'bytes', 3],
['PREFIX_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 'short', 1],
['PREFIX_seconds_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 's', 1],
['PREFIX_bytes_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 'bytes', 1],
])('Given metric %p expect %p with unit %p', (metric, expr, unit, queryCount) => {
const result = getAutoQueriesForMetric(metric);
@ -173,32 +179,32 @@ describe('getAutoQueriesForMetric', () => {
describe('Consider result.preview query (only first)', () => {
it.each([
// no rate
['PREFIX_general', 'avg(...)', 'short'],
['PREFIX_bytes', 'avg(...)', 'bytes'],
['PREFIX_seconds', 'avg(...)', 's'],
['PREFIX_general', 'avg(... ${otel_join_query})', 'short'],
['PREFIX_bytes', 'avg(... ${otel_join_query})', 'bytes'],
['PREFIX_seconds', 'avg(... ${otel_join_query})', 's'],
// rate with counts per second
['PREFIX_count', 'sum(rate(...[$__rate_interval]))', 'cps'], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]))', 'cps'],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))', 'cps'],
['PREFIX_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps'], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps'],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'cps'],
// rate with seconds per second
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))', 'short'], // s/s
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'short'], // s/s
// rate with bytes per second
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))', 'Bps'], // bytes/s
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})', 'Bps'], // bytes/s
// mean with non-rated units
[
'PREFIX_seconds_sum',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]) ${otel_join_query})/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]) ${otel_join_query})',
's',
],
[
'PREFIX_bytes_sum',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]) ${otel_join_query})/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]) ${otel_join_query})',
'bytes',
],
// Bucket
['PREFIX_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 'short'],
['PREFIX_seconds_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 's'],
['PREFIX_bytes_bucket', 'histogram_quantile(0.5, sum by(le) (rate(...[$__rate_interval])))', 'bytes'],
['PREFIX_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 'short'],
['PREFIX_seconds_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 's'],
['PREFIX_bytes_bucket', 'sum by(le) (rate(...[$__rate_interval])${otel_join_query})', 'bytes'],
])('Given metric %p expect %p with unit %p', (metric, expr, unit) => {
const result = getAutoQueriesForMetric(metric);
@ -216,32 +222,44 @@ describe('getAutoQueriesForMetric', () => {
describe('Consider result.breakdown query (only first)', () => {
it.each([
// no rate
['PREFIX_general', 'avg(...)by(${groupby})', 'short'],
['PREFIX_bytes', 'avg(...)by(${groupby})', 'bytes'],
['PREFIX_seconds', 'avg(...)by(${groupby})', 's'],
['PREFIX_general', 'avg(... ${otel_join_query})by(${groupby})', 'short'],
['PREFIX_bytes', 'avg(... ${otel_join_query})by(${groupby})', 'bytes'],
['PREFIX_seconds', 'avg(... ${otel_join_query})by(${groupby})', 's'],
// rate with counts per second
['PREFIX_count', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'cps'],
['PREFIX_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})by(${groupby})', 'cps'], // cps = counts per second
['PREFIX_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})by(${groupby})', 'cps'],
['PREFIX_seconds_count', 'sum(rate(...[$__rate_interval]) ${otel_join_query})by(${groupby})', 'cps'],
// rate with seconds per second
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'short'], // s/s
['PREFIX_seconds_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})by(${groupby})', 'short'], // s/s
// rate with bytes per second
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]))by(${groupby})', 'Bps'], // bytes/s
['PREFIX_bytes_total', 'sum(rate(...[$__rate_interval]) ${otel_join_query})by(${groupby})', 'Bps'], // bytes/s
// mean with non-rated units
[
'PREFIX_seconds_sum',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]))by(${groupby})/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]))by(${groupby})',
'sum(rate(PREFIX_seconds_sum{${filters}}[$__rate_interval]) ${otel_join_query})by(${groupby})/sum(rate(PREFIX_seconds_count{${filters}}[$__rate_interval]) ${otel_join_query})by(${groupby})',
's',
],
[
'PREFIX_bytes_sum',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]))by(${groupby})/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]))by(${groupby})',
'sum(rate(PREFIX_bytes_sum{${filters}}[$__rate_interval]) ${otel_join_query})by(${groupby})/sum(rate(PREFIX_bytes_count{${filters}}[$__rate_interval]) ${otel_join_query})by(${groupby})',
'bytes',
],
// Bucket
['PREFIX_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 'short'],
['PREFIX_seconds_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 's'],
['PREFIX_bytes_bucket', 'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])))', 'bytes'],
[
'PREFIX_bucket',
'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])${otel_join_query}))',
'short',
],
[
'PREFIX_seconds_bucket',
'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])${otel_join_query}))',
's',
],
[
'PREFIX_bytes_bucket',
'histogram_quantile(0.5, sum by(le, ${groupby}) (rate(...[$__rate_interval])${otel_join_query}))',
'bytes',
],
])('Given metric %p expect %p with unit %p', (metric, expr, unit) => {
const result = getAutoQueriesForMetric(metric);
@ -274,15 +292,15 @@ describe('getAutoQueriesForMetric', () => {
variant: 'percentiles',
unit: 'short',
exprs: [
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
],
},
{
variant: 'heatmap',
unit: 'short',
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'],
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query})'],
},
],
],
@ -293,15 +311,15 @@ describe('getAutoQueriesForMetric', () => {
variant: 'percentiles',
unit: 's',
exprs: [
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
],
},
{
variant: 'heatmap',
unit: 's',
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'],
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query})'],
},
],
],
@ -312,15 +330,15 @@ describe('getAutoQueriesForMetric', () => {
variant: 'percentiles',
unit: 'bytes',
exprs: [
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])))',
'histogram_quantile(0.99, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.9, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
'histogram_quantile(0.5, sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query}))',
],
},
{
variant: 'heatmap',
unit: 'bytes',
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval]))'],
exprs: ['sum by(le) (rate(${metric}{${filters}}[$__rate_interval])${otel_join_query})'],
},
],
],
@ -338,7 +356,7 @@ describe('getAutoQueriesForMetric', () => {
});
describe('Able to handle unconventional metric names', () => {
it.each([['PRODUCT_High_Priority_items_', 'avg(...)', 'short', 1]])(
it.each([['PRODUCT_High_Priority_items_', 'avg(... ${otel_join_query})', 'short', 1]])(
'Given metric %p expect %p with unit %p',
(metric, expr, unit, queryCount) => {
const result = getAutoQueriesForMetric(metric);

View File

@ -1,8 +1,10 @@
import { VAR_METRIC_EXPR, VAR_FILTERS_EXPR } from 'app/features/trails/shared';
import { VAR_METRIC_EXPR, VAR_FILTERS_EXPR, VAR_OTEL_JOIN_QUERY_EXPR } from 'app/features/trails/shared';
const GENERAL_BASE_QUERY = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`;
const GENERAL_RATE_BASE_QUERY = `rate(${GENERAL_BASE_QUERY}[$__rate_interval])`;
export function getGeneralBaseQuery(rate: boolean) {
return rate ? GENERAL_RATE_BASE_QUERY : GENERAL_BASE_QUERY;
return rate
? `${GENERAL_RATE_BASE_QUERY} ${VAR_OTEL_JOIN_QUERY_EXPR}`
: `${GENERAL_BASE_QUERY} ${VAR_OTEL_JOIN_QUERY_EXPR}`;
}

View File

@ -1,6 +1,6 @@
import { PromQuery } from '@grafana/prometheus';
import { VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../../shared';
import { VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR, VAR_OTEL_JOIN_QUERY_EXPR } from '../../shared';
import { heatmapGraphBuilder } from '../graph-builders/heatmap';
import { percentilesGraphBuilder } from '../graph-builders/percentiles';
import { simpleGraphBuilder } from '../graph-builders/simple';
@ -47,10 +47,10 @@ export function createHistogramMetricQueryDefs(metricParts: string[]) {
vizBuilder: () => heatmapGraphBuilder(heatmap),
};
return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown };
return { preview: heatmap, main: heatmap, variants: [percentiles, heatmap], breakdown: breakdown };
}
const BASE_QUERY = `rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])`;
const BASE_QUERY = `rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])${VAR_OTEL_JOIN_QUERY_EXPR}`;
function baseQuery(groupings: string[] = []) {
const sumByList = ['le', ...groupings];

View File

@ -1,5 +1,6 @@
import { VariableHide } from '@grafana/data';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
import { AdHocFiltersVariable, ConstantVariable, CustomVariable, sceneGraph } from '@grafana/scenes';
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
import { MockDataSourceSrv, mockDataSource } from '../alerting/unified/mocks';
@ -8,10 +9,23 @@ import { activateFullSceneTree } from '../dashboard-scene/utils/test-utils';
import { DataTrail } from './DataTrail';
import { MetricScene } from './MetricScene';
import { MetricSelectScene } from './MetricSelect/MetricSelectScene';
import { MetricSelectedEvent, VAR_FILTERS } from './shared';
import {
MetricSelectedEvent,
VAR_FILTERS,
VAR_OTEL_DEPLOYMENT_ENV,
VAR_OTEL_JOIN_QUERY,
VAR_OTEL_RESOURCES,
} from './shared';
jest.mock('./otel/api', () => ({
totalOtelResources: jest.fn(() => ({ job: 'oteldemo', instance: 'instance' })),
getDeploymentEnvironments: jest.fn(() => ['production', 'staging']),
isOtelStandardization: jest.fn(() => true),
}));
describe('DataTrail', () => {
beforeAll(() => {
jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve());
setDataSourceSrv(
new MockDataSourceSrv({
prom: mockDataSource({
@ -22,6 +36,10 @@ describe('DataTrail', () => {
);
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('Given starting non-embedded trail with url sync and no url state', () => {
let trail: DataTrail;
const preTrailUrl = '/';
@ -459,4 +477,74 @@ describe('DataTrail', () => {
});
});
});
describe('OTel resources attributes', () => {
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');
}
beforeEach(() => {
trail = new DataTrail({});
locationService.push(preTrailUrl);
activateFullSceneTree(trail);
getOtelResourcesVar(trail).setState({ filters: [{ key: 'service_name', operator: '=', value: 'adservice' }] });
getOtelDepEnvVar(trail).changeValueTo('production');
});
it('should start with hidden dep env variable', () => {
const depEnvVarHide = getOtelDepEnvVar(trail).state.hide;
expect(depEnvVarHide).toBe(VariableHide.hideVariable);
});
it('should start with hidden otel resources variable', () => {
const resourcesVarHide = getOtelResourcesVar(trail).state.hide;
expect(resourcesVarHide).toBe(VariableHide.hideVariable);
});
it('should start with hidden otel join query variable', () => {
const joinQueryVarHide = getOtelJoinQueryVar(trail).state.hide;
expect(joinQueryVarHide).toBe(VariableHide.hideVariable);
});
it('should add history step for when updating the otel resource variable', () => {
expect(trail.state.history.state.steps[2].type).toBe('resource');
});
it('Should have otel resource attribute selected as "service_name=adservice"', () => {
expect(getOtelResourcesVar(trail).state.filters[0].key).toBe('service_name');
expect(getOtelResourcesVar(trail).state.filters[0].value).toBe('adservice');
});
it('Should have deployment environment selected as "production"', () => {
expect(getOtelDepEnvVar(trail).getValue()).toBe('production');
});
it('should add history step for when updating the dep env variable', () => {
expect(trail.state.history.state.steps[3].type).toBe('dep_env');
});
});
});

View File

@ -1,9 +1,20 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { AdHocVariableFilter, GrafanaTheme2, urlUtil, VariableHide } from '@grafana/data';
import {
AdHocVariableFilter,
GetTagResponse,
GrafanaTheme2,
MetricFindValue,
RawTimeRange,
VariableHide,
urlUtil,
} from '@grafana/data';
import { config, locationService, useChromeHeaderHeight } from '@grafana/runtime';
import {
AdHocFiltersVariable,
ConstantVariable,
CustomVariable,
DataSourceVariable,
SceneComponentProps,
SceneControlsSpacer,
@ -33,7 +44,21 @@ import { MetricsHeader } from './MetricsHeader';
import { getTrailStore } from './TrailStore/TrailStore';
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
import { reportChangeInLabelFilters } from './interactions';
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
import { getDeploymentEnvironments, TARGET_INFO_FILTER, totalOtelResources } from './otel/api';
import { OtelResourcesObject, OtelTargetType } from './otel/types';
import { sortResources, getOtelJoinQuery, getOtelResourcesObject } from './otel/util';
import {
getVariablesWithOtelJoinQueryConstant,
MetricSelectedEvent,
trailDS,
VAR_DATASOURCE,
VAR_DATASOURCE_EXPR,
VAR_FILTERS,
VAR_OTEL_DEPLOYMENT_ENV,
VAR_OTEL_JOIN_QUERY,
VAR_OTEL_RESOURCES,
} from './shared';
import { getTrailFor } from './utils';
export interface DataTrailState extends SceneObjectState {
topScene?: SceneObject;
@ -47,6 +72,16 @@ export interface DataTrailState extends SceneObjectState {
initialDS?: string;
initialFilters?: AdHocVariableFilter[];
// this is for otel, if the data source has it, it will be updated here
hasOtelResources?: boolean;
useOtelExperience?: boolean;
otelTargets?: OtelTargetType; // all the targets with job and instance regex, job=~"<job-v>|<job-v>"", instance=~"<instance-v>|<instance-v>"
otelJoinQuery?: string;
isStandardOtel?: boolean;
// moved into settings
showPreviews?: boolean;
// Synced with url
metric?: string;
metricSearch?: string;
@ -58,7 +93,10 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
public constructor(state: Partial<DataTrailState>) {
super({
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
$variables: state.$variables ?? getVariableSet(state.initialDS, state.metric, state.initialFilters),
// the initial variables should include a metric for metric scene and the otelJoinQuery.
// NOTE: The other OTEL filters should be included too before this work is merged
$variables:
state.$variables ?? getVariableSet(state.initialDS, state.metric, state.initialFilters, state.otelJoinQuery),
controls: state.controls ?? [
new VariableValueSelectors({ layout: 'vertical' }),
new SceneControlsSpacer(),
@ -68,6 +106,12 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
history: state.history ?? new DataTrailHistory({}),
settings: state.settings ?? new DataTrailSettings({}),
createdAt: state.createdAt ?? new Date().getTime(),
// default to false but update this to true on checkOtelSandardization()
// or true if the user either turned on the experience
useOtelExperience: state.useOtelExperience ?? false,
// preserve the otel join query
otelJoinQuery: state.otelJoinQuery ?? '',
showPreviews: true,
...state,
});
@ -106,11 +150,45 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_DATASOURCE],
variableNames: [VAR_DATASOURCE, VAR_OTEL_RESOURCES, VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_JOIN_QUERY],
onReferencedVariableValueChanged: async (variable: SceneVariable) => {
const { name } = variable.state;
if (name === VAR_DATASOURCE) {
this.datasourceHelper.reset();
// fresh check for otel experience
this.checkDataSourceForOTelResources();
// clear filters on resetting the data source
const adhocVariable = sceneGraph.lookupVariable(VAR_FILTERS, this);
if (adhocVariable instanceof AdHocFiltersVariable) {
adhocVariable.setState({ filters: [] });
}
}
// update otel variables when changed
if (this.state.useOtelExperience && (name === VAR_OTEL_DEPLOYMENT_ENV || name === VAR_OTEL_RESOURCES)) {
// for state and variables
const timeRange: RawTimeRange | undefined = this.state.$timeRange?.state;
const datasourceUid = sceneGraph.interpolate(this, VAR_DATASOURCE_EXPR);
const otelDepEnvVariable = sceneGraph.lookupVariable(VAR_OTEL_DEPLOYMENT_ENV, this);
const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, this);
const otelJoinQueryVariable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, this);
if (
timeRange &&
otelResourcesVariable instanceof AdHocFiltersVariable &&
otelJoinQueryVariable instanceof ConstantVariable &&
otelDepEnvVariable instanceof CustomVariable
) {
this.updateOtelData(
datasourceUid,
timeRange,
otelDepEnvVariable,
otelResourcesVariable,
otelJoinQueryVariable
);
}
}
},
});
@ -208,12 +286,304 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
this.setState(stateUpdate);
}
/**
* Check that the data source has otel resources
* Check that the data source is standard for OTEL
* Show a warning if not
* Update the following variables:
* deployment_environment (first filter), otelResources (filters), otelJoinQuery (used in the query)
* Enable the otel experience
*
* @returns
*/
public async checkDataSourceForOTelResources() {
// call up in to the parent trail
const trail = getTrailFor(this);
// get the time range
const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state;
if (timeRange) {
const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, this);
const otelDepEnvVariable = sceneGraph.lookupVariable(VAR_OTEL_DEPLOYMENT_ENV, this);
const otelJoinQueryVariable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, this);
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, this);
const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
const otelTargets = await totalOtelResources(datasourceUid, timeRange);
const deploymentEnvironments = await getDeploymentEnvironments(datasourceUid, timeRange);
const hasOtelResources = otelTargets.jobs.length > 0 && otelTargets.instances.length > 0;
if (
otelResourcesVariable instanceof AdHocFiltersVariable &&
otelDepEnvVariable instanceof CustomVariable &&
otelJoinQueryVariable instanceof ConstantVariable &&
filtersVariable instanceof AdHocFiltersVariable
) {
// HERE WE START THE OTEL EXPERIENCE ENGINE
// 1. Set deployment variable values
// 2. update all other variables and state
if (hasOtelResources && deploymentEnvironments.length > 0) {
// apply VAR FILTERS manually
// otherwise they will appear anywhere the query contains {} characters
filtersVariable.setState({
addFilterButtonText: 'Select metric attributes',
label: 'Select metric attribute',
});
// 1. set deployment variable values
let varQuery = '';
const options = deploymentEnvironments.map((env) => {
varQuery += env + ',';
return { value: env, label: env };
});
// We have to have a default value because custom variable requires it
// we choose one default value to help filter metrics
// The work flow for OTel begins with users selecting a deployment environment
const defaultDepEnv = options[0].value; // usually production
// On starting the explore metrics workflow, the custom variable has no value
// Even if there is state, the value is always ''
// The only reference to state values are in the text
const otelDepEnvValue = otelDepEnvVariable.state.text;
// TypeScript issue: VariableValue is either a string or array but does not have any string or array methods on it to check that it is empty
const notInitialvalue = otelDepEnvValue !== '' && otelDepEnvValue.toLocaleString() !== '';
const depEnvInitialValue = notInitialvalue ? otelDepEnvValue : defaultDepEnv;
otelDepEnvVariable?.setState({
value: depEnvInitialValue,
options: options,
hide: VariableHide.dontHide,
});
this.updateOtelData(
datasourceUid,
timeRange,
otelDepEnvVariable,
otelResourcesVariable,
otelJoinQueryVariable,
deploymentEnvironments,
hasOtelResources
);
} else {
// reset filters to apply auto, anywhere there are {} characters
this.resetOtelExperience(
otelResourcesVariable,
otelDepEnvVariable,
otelJoinQueryVariable,
filtersVariable,
hasOtelResources,
deploymentEnvironments
);
}
}
}
}
/**
* This function is used to update state and otel variables
*
* 1. Set the otelResources adhoc tagKey and tagValues filter functions
2. Get the otel join query for state and variable
3. Update state with the following
- otel join query
- otelTargets used to filter metrics
For initialization we also update the following
- has otel resources flag
- isStandardOtel flag (for enabliing the otel experience toggle)
- and useOtelExperience
* @param datasourceUid
* @param timeRange
* @param otelDepEnvVariable
* @param otelResourcesVariable
* @param otelJoinQueryVariable
* @param deploymentEnvironments
* @param hasOtelResources
*/
async updateOtelData(
datasourceUid: string,
timeRange: RawTimeRange,
otelDepEnvVariable: CustomVariable,
otelResourcesVariable: AdHocFiltersVariable,
otelJoinQueryVariable: ConstantVariable,
deploymentEnvironments?: string[],
hasOtelResources?: boolean
) {
// 1. Set the otelResources adhoc tagKey and tagValues filter functions
// get the labels for otel resources
// collection of filters for the otel resource variable
// filter label names and label values
// the first filter is {__name__="target_info"}
let filters: AdHocVariableFilter[] = [TARGET_INFO_FILTER];
// always start with the deployment environment
const depEnvValue = '' + otelDepEnvVariable?.getValue();
if (depEnvValue) {
// update the operator if more than one
const op = depEnvValue.includes(',') ? '=~' : '=';
// the second filter is deployment_environment
const filter = {
key: 'deployment_environment',
value: depEnvValue.split(',').join('|'),
operator: op,
};
filters.push(filter);
}
// next we check the otel resources adhoc variable for filters
const values = otelResourcesVariable.getValue();
if (values && otelResourcesVariable.state.filters.length > 0) {
filters = filters.concat(otelResourcesVariable.state.filters);
}
// the datasourceHelper will give us access to the
// Prometheus functions getTagKeys and getTagValues
// because we can access the ds
const datasourceHelper = this.datasourceHelper;
// now we reset the override tagKeys and tagValues functions of the adhoc variable
otelResourcesVariable.setState({
getTagKeysProvider: async (
variable: AdHocFiltersVariable,
currentKey: string | null
): Promise<{
replace?: boolean;
values: GetTagResponse | MetricFindValue[];
}> => {
// apply filters here
let values = await datasourceHelper.getTagKeys({ filters });
values = sortResources(values, filters.map((f) => f.key).concat(currentKey ?? ''));
return { replace: true, values };
},
getTagValuesProvider: async (
variable: AdHocFiltersVariable,
filter: AdHocVariableFilter
): Promise<{
replace?: boolean;
values: GetTagResponse | MetricFindValue[];
}> => {
// apply filters here
// remove current selected filter if refiltering
filters = filters.filter((f) => f.key !== filter.key);
const values = await datasourceHelper.getTagValues({ key: filter.key, filters });
return { replace: true, values };
},
hide: VariableHide.hideLabel,
});
// 2. Get the otel join query for state and variable
// Because we need to define the deployment environment variable
// we also need to update the otel join query state and variable
const resourcesObject: OtelResourcesObject = getOtelResourcesObject(this);
const otelJoinQuery = getOtelJoinQuery(resourcesObject);
// update the otel join query variable too
otelJoinQueryVariable.setState({ value: otelJoinQuery });
// 3. Update state with the following
// - otel join query
// - otelTargets used to filter metrics
// now we can filter target_info targets by deployment_environment="somevalue"
// and use these new targets to reduce the metrics
// for initialization we also update the following
// - has otel resources flag
// - and default to useOtelExperience
const otelTargets = await totalOtelResources(datasourceUid, timeRange, resourcesObject.filters);
// we pass in deploymentEnvironments and hasOtelResources on start
if (hasOtelResources && deploymentEnvironments) {
this.setState({
otelTargets,
otelJoinQuery,
hasOtelResources,
isStandardOtel: deploymentEnvironments.length > 0,
useOtelExperience: true,
});
} else {
// we are updating on variable changes
this.setState({
otelTargets,
otelJoinQuery,
});
}
}
resetOtelExperience(
otelResourcesVariable: AdHocFiltersVariable,
otelDepEnvVariable: CustomVariable,
otelJoinQueryVariable: ConstantVariable,
filtersVariable: AdHocFiltersVariable,
hasOtelResources?: boolean,
deploymentEnvironments?: string[]
) {
// reset filters to apply auto, anywhere there are {} characters
filtersVariable.setState({
addFilterButtonText: 'Add label',
label: 'Select label',
});
// if there are no resources reset the otel variables and otel state
// or if not standard
otelResourcesVariable.setState({
defaultKeys: [],
hide: VariableHide.hideVariable,
});
otelDepEnvVariable.setState({
value: '',
hide: VariableHide.hideVariable,
});
otelJoinQueryVariable.setState({ value: '' });
// full reset when a data source fails the check
if (hasOtelResources && deploymentEnvironments) {
this.setState({
hasOtelResources,
isStandardOtel: deploymentEnvironments.length > 0,
useOtelExperience: false,
otelTargets: { jobs: [], instances: [] },
otelJoinQuery: '',
});
} else {
// partial reset when a user turns off the otel experience
this.setState({
otelTargets: { jobs: [], instances: [] },
otelJoinQuery: '',
});
}
}
static Component = ({ model }: SceneComponentProps<DataTrail>) => {
const { controls, topScene, history, settings } = model.useState();
const { controls, topScene, history, settings, useOtelExperience, hasOtelResources } = model.useState();
const chromeHeaderHeight = useChromeHeaderHeight();
const styles = useStyles2(getStyles, chromeHeaderHeight ?? 0);
const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2;
useEffect(() => {
// check if the otel experience has been enabled
if (!useOtelExperience) {
// if the experience has been turned off, reset the otel variables
const otelResourcesVariable = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, model);
const otelDepEnvVariable = sceneGraph.lookupVariable(VAR_OTEL_DEPLOYMENT_ENV, model);
const otelJoinQueryVariable = sceneGraph.lookupVariable(VAR_OTEL_JOIN_QUERY, model);
const filtersvariable = sceneGraph.lookupVariable(VAR_FILTERS, model);
if (
otelResourcesVariable instanceof AdHocFiltersVariable &&
otelDepEnvVariable instanceof CustomVariable &&
otelJoinQueryVariable instanceof ConstantVariable &&
filtersvariable instanceof AdHocFiltersVariable
) {
model.resetOtelExperience(otelResourcesVariable, otelDepEnvVariable, otelJoinQueryVariable, filtersvariable);
}
} else {
// if experience is enabled, check standardization and update the otel variables
model.checkDataSourceForOTelResources();
}
}, [model, hasOtelResources, useOtelExperience]);
return (
<div className={styles.container}>
{showHeaderForFirstTimeUsers && <MetricsHeader />}
@ -240,7 +610,12 @@ export function getTopSceneFor(metric?: string) {
}
}
function getVariableSet(initialDS?: string, metric?: string, initialFilters?: AdHocVariableFilter[]) {
function getVariableSet(
initialDS?: string,
metric?: string,
initialFilters?: AdHocVariableFilter[],
otelJoinQuery?: string
) {
return new SceneVariableSet({
variables: [
new DataSourceVariable({
@ -250,6 +625,24 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad
value: initialDS,
pluginId: 'prometheus',
}),
new CustomVariable({
name: VAR_OTEL_DEPLOYMENT_ENV,
label: 'Deployment environment',
hide: VariableHide.hideVariable,
value: undefined,
placeholder: 'Select',
isMulti: true,
}),
new AdHocFiltersVariable({
name: VAR_OTEL_RESOURCES,
label: 'Select resource attributes',
addFilterButtonText: 'Select resource attributes',
datasource: trailDS,
hide: VariableHide.hideVariable,
layout: 'vertical',
defaultKeys: [],
applyMode: 'manual',
}),
new AdHocFiltersVariable({
name: VAR_FILTERS,
addFilterButtonText: 'Add label',
@ -258,9 +651,11 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad
layout: config.featureToggles.newFiltersUI ? 'combobox' : 'vertical',
filters: initialFilters ?? [],
baseFilters: getBaseFiltersForMetric(metric),
applyMode: 'manual',
// since we only support prometheus datasources, this is always true
supportsMultiValueOperators: true,
}),
...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''),
],
});
}

View File

@ -3,8 +3,12 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui';
import { Trans } from '@grafana/ui/src/utils/i18n';
import { MetricScene } from './MetricScene';
import { MetricSelectScene } from './MetricSelect/MetricSelectScene';
import { reportExploreMetrics } from './interactions';
import { getTrailFor } from './utils';
export interface DataTrailSettingsState extends SceneObjectState {
stickyMainGraph?: boolean;
@ -29,19 +33,42 @@ export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> {
this.setState({ isOpen });
};
public onTogglePreviews = () => {
const trail = getTrailFor(this);
trail.setState({ showPreviews: !trail.state.showPreviews });
};
static Component = ({ model }: SceneComponentProps<DataTrailSettings>) => {
const { stickyMainGraph, isOpen } = model.useState();
const styles = useStyles2(getStyles);
const trail = getTrailFor(model);
const { showPreviews, topScene } = trail.useState();
const renderPopover = () => {
return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className={styles.popover} onClick={(evt) => evt.stopPropagation()}>
<div className={styles.heading}>Settings</div>
<div className={styles.options}>
<div>Always keep selected metric graph in-view</div>
<Switch value={stickyMainGraph} onChange={model.onToggleStickyMainGraph} />
</div>
{topScene instanceof MetricScene && (
<div className={styles.options}>
<div>
<Trans i18nKey="trails.settings.always-keep-selected-metric-graph-in-view">
Always keep selected metric graph in-view
</Trans>
</div>
<Switch value={stickyMainGraph} onChange={model.onToggleStickyMainGraph} />
</div>
)}
{topScene instanceof MetricSelectScene && (
<div className={styles.options}>
<div>
<Trans i18nKey="trails.settings.show-previews-of-metric-graphs">Show previews of metric graphs</Trans>
</div>
<Switch value={showPreviews} onChange={model.onTogglePreviews} />
</div>
)}
</div>
);
};

View File

@ -19,13 +19,15 @@ import { Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
import { SerializedTrailHistory } from './TrailStore/TrailStore';
import { reportExploreMetrics } from './interactions';
import { VAR_FILTERS } from './shared';
import { VAR_FILTERS, VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_RESOURCES } from './shared';
import { getTrailFor, isSceneTimeRangeState } from './utils';
export interface DataTrailsHistoryState extends SceneObjectState {
currentStep: number;
steps: DataTrailHistoryStep[];
filtersApplied: string[];
otelResources: string[];
otelDepEnvs: string[];
}
export function isDataTrailsHistoryState(state: SceneObjectState): state is DataTrailsHistoryState {
@ -46,7 +48,7 @@ export interface DataTrailHistoryStep {
parentIndex: number;
}
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start' | 'metric_page';
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start' | 'metric_page' | 'dep_env' | 'resource';
const filterSubst = ` $2 `;
const filterPipeRegex = /(\|)(=|=~|!=|>|<|!~)(\|)/g;
@ -56,11 +58,19 @@ const stepDescriptionMap: Record<TrailStepType, string> = {
metric_page: 'Metric select page',
filters: 'Filter applied:',
time: 'Time range changed:',
dep_env: 'Deployment environment selected:',
resource: 'Resource attribute selected:',
};
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0, filtersApplied: [] });
super({
steps: state.steps ?? [],
currentStep: state.currentStep ?? 0,
filtersApplied: [],
otelResources: [],
otelDepEnvs: [],
});
this.addActivationHandler(this._onActivate.bind(this));
}
@ -113,6 +123,20 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
this.addTrailStep(trail, 'filters', parseFilterTooltip(urlState, filtersApplied));
this.setState({ filtersApplied });
}
if (evt.payload.state.name === VAR_OTEL_DEPLOYMENT_ENV) {
const otelDepEnvs = this.state.otelDepEnvs;
const urlState = sceneUtils.getUrlState(trail);
this.addTrailStep(trail, 'dep_env', parseDepEnvTooltip(urlState, otelDepEnvs));
this.setState({ otelDepEnvs });
}
if (evt.payload.state.name === VAR_OTEL_RESOURCES) {
const otelResources = this.state.otelResources;
const urlState = sceneUtils.getUrlState(trail);
this.addTrailStep(trail, 'resource', parseOtelResourcesTooltip(urlState, otelResources));
this.setState({ otelResources });
}
});
trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
@ -172,6 +196,8 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
const stepIndex = this.state.steps.length;
const parentIndex = type === 'start' ? -1 : this.state.currentStep;
const filtersApplied = this.state.filtersApplied;
const otelResources = this.state.otelResources;
const otelDepEnvs = this.state.otelDepEnvs;
let detail = '';
switch (step.type) {
@ -184,10 +210,16 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
case 'time':
detail = parseTimeTooltip(step.urlValues);
break;
case 'dep_env':
detail = parseDepEnvTooltip(step.urlValues, otelDepEnvs);
case 'resource':
detail = parseOtelResourcesTooltip(step.urlValues, otelResources);
}
this.setState({
filtersApplied,
otelDepEnvs,
otelResources,
currentStep: stepIndex,
steps: [
...this.state.steps,
@ -336,6 +368,46 @@ export function parseFilterTooltip(urlValues: SceneObjectUrlValues, filtersAppli
return detail.replace(filterPipeRegex, filterSubst);
}
export function parseOtelResourcesTooltip(urlValues: SceneObjectUrlValues, otelResources: string[]): string {
let detail = '';
const varOtelResources = urlValues['var-otel_resources'];
if (isDataTrailHistoryFilter(varOtelResources)) {
detail =
varOtelResources.filter((f) => {
if (f !== '' && !otelResources.includes(f)) {
otelResources.push(f);
return true;
}
return false;
})[0] ?? '';
}
// filters saved as key|operator|value
// we need to remove pipes (|)
return detail.replace(filterPipeRegex, filterSubst);
}
export function parseDepEnvTooltip(urlValues: SceneObjectUrlValues, otelDepEnvs: string[]): string {
let detail = '';
const varDepEnv = urlValues['var-deployment_environment'];
if (typeof varDepEnv === 'string') {
return varDepEnv;
}
if (isDataTrailHistoryFilter(varDepEnv)) {
detail =
varDepEnv?.filter((f) => {
if (f !== '' && !otelDepEnvs.includes(f)) {
otelDepEnvs.push(f);
return true;
}
return false;
})[0] ?? '';
}
return detail;
}
function getStyles(theme: GrafanaTheme2) {
const visTheme = theme.visualization;
@ -408,6 +480,8 @@ function getStyles(theme: GrafanaTheme2) {
metric: generateStepTypeStyle(visTheme.getColorByName('orange')),
metric_page: generateStepTypeStyle(visTheme.getColorByName('orange')),
time: generateStepTypeStyle(theme.colors.primary.main),
resource: generateStepTypeStyle(visTheme.getColorByName('purple')),
dep_env: generateStepTypeStyle(visTheme.getColorByName('purple')),
},
};
}

View File

@ -34,6 +34,7 @@ import { StatusWrapper } from '../StatusWrapper';
import { Node, Parser } from '../groop/parser';
import { getMetricDescription } from '../helpers/MetricDatasourceHelper';
import { reportExploreMetrics } from '../interactions';
import { limitOtelMatchTerms } from '../otel/util';
import {
getVariablesWithMetricConstant,
MetricSelectedEvent,
@ -62,7 +63,6 @@ export interface MetricSelectSceneState extends SceneObjectState {
body: SceneFlexLayout | SceneCSSGridLayout;
rootGroup?: Node;
metricPrefix?: string;
showPreviews?: boolean;
metricNames?: string[];
metricNamesLoading?: boolean;
metricNamesError?: string;
@ -85,7 +85,6 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
constructor(state: Partial<MetricSelectSceneState>) {
super({
showPreviews: true,
$variables: state.$variables,
metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL,
body:
@ -182,6 +181,34 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
}
});
this._subs.add(
trail.subscribeToState(({ otelTargets }, oldState) => {
// if the otel targets have changed, get the new list of metrics
if (
otelTargets?.instances !== oldState.otelTargets?.instances &&
otelTargets?.jobs !== oldState.otelTargets?.jobs
) {
this._debounceRefreshMetricNames();
}
})
);
this._subs.add(
trail.subscribeToState(({ useOtelExperience }, oldState) => {
// users will most likely not switch this off but for now,
// update metric names when changing useOtelExperience
this._debounceRefreshMetricNames();
})
);
this._subs.add(
trail.subscribeToState(({ showPreviews }, oldState) => {
// move showPreviews into the settings
// build layout when toggled
this.buildLayout();
})
);
this._debounceRefreshMetricNames();
}
@ -193,7 +220,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
return;
}
const matchTerms = [];
const matchTerms: string[] = [];
const filtersVar = sceneGraph.lookupVariable(VAR_FILTERS, this);
const hasFilters = filtersVar instanceof AdHocFiltersVariable && filtersVar.getValue()?.valueOf();
@ -206,6 +233,26 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
matchTerms.push(`__name__=~"${metricSearchRegex}"`);
}
let noOtelMetrics = false;
let missingOtelTargets = false;
if (trail.state.useOtelExperience) {
const jobsList = trail.state.otelTargets?.jobs;
const instancesList = trail.state.otelTargets?.instances;
// no targets have this combination of filters so there are no metrics that can be joined
// show no metrics
if (jobsList && jobsList.length > 0 && instancesList && instancesList.length > 0) {
const otelMatches = limitOtelMatchTerms(matchTerms, jobsList, instancesList, missingOtelTargets);
missingOtelTargets = otelMatches.missingOtelTargets;
matchTerms.push(otelMatches.jobsRegex);
matchTerms.push(otelMatches.instancesRegex);
} else {
noOtelMetrics = true;
}
}
const match = `{${matchTerms.join(',')}}`;
const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
this.setState({ metricNamesLoading: true, metricNamesError: undefined, metricNamesWarning: undefined });
@ -227,12 +274,23 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
metricNames = metricNames.filter((metric) => !prefixRegex || prefixRegex.test(metric));
}
const metricNamesWarning = response.limitReached
let metricNamesWarning = response.limitReached
? `This feature will only return up to ${MAX_METRIC_NAMES} metric names for performance reasons. ` +
`This limit is being exceeded for the current data source. ` +
`Add search terms or label filters to narrow down the number of metric names returned.`
: undefined;
// if there are no otel targets for otel resources, there will be no labels
if (noOtelMetrics) {
metricNames = [];
metricNamesWarning = undefined;
}
if (missingOtelTargets) {
metricNamesWarning +=
'The list of metrics is not complete. Select more OTel resource attributes to see a full list of metrics.';
}
let bodyLayout = this.state.body;
let rootGroupNode = this.state.rootGroup;
@ -340,6 +398,8 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
}
private async buildLayout() {
const trail = getTrailFor(this);
const showPreviews = trail.state.showPreviews;
// Temp hack when going back to select metric scene and variable updates
if (this.ignoreNextUpdate) {
this.ignoreNextUpdate = false;
@ -348,8 +408,6 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
const children: SceneFlexItem[] = [];
const trail = getTrailFor(this);
const metricsList = this.sortedPreviewMetrics();
// Get the current filters to determine the count of them
@ -362,7 +420,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
const metadata = await trail.getMetricMetadata(metric.name);
const description = getMetricDescription(metadata);
if (this.state.showPreviews) {
if (showPreviews) {
if (metric.itemRef && metric.isPanel) {
children.push(metric.itemRef.resolve());
continue;
@ -385,7 +443,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
}
}
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
const rowTemplate = showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
this.state.body.setState({ children, autoRows: rowTemplate });
}
@ -426,29 +484,23 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
});
};
public onTogglePreviews = () => {
this.setState({ showPreviews: !this.state.showPreviews });
this.buildLayout();
public onToggleOtelExperience = () => {
const trail = getTrailFor(this);
const useOtelExperience = trail.state.useOtelExperience;
trail.setState({ useOtelExperience: !useOtelExperience });
};
public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => {
const {
showPreviews,
body,
metricNames,
metricNamesError,
metricNamesLoading,
metricNamesWarning,
rootGroup,
metricPrefix,
} = model.useState();
const { body, metricNames, metricNamesError, metricNamesLoading, metricNamesWarning, rootGroup, metricPrefix } =
model.useState();
const { children } = body.useState();
const trail = getTrailFor(model);
const styles = useStyles2(getStyles);
const [warningDismissed, dismissWarning] = useReducer(() => true, false);
const { metricSearch } = trail.useState();
const { metricSearch, useOtelExperience, hasOtelResources, isStandardOtel } = trail.useState();
const tooStrict = children.length === 0 && metricSearch;
const noMetrics = !metricNamesLoading && metricNames && metricNames.length === 0;
@ -509,7 +561,40 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
]}
/>
</Field>
<InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} />
{hasOtelResources && (
<Field
label={
<div className={styles.displayOptionTooltip}>
<Trans i18nKey="trails.metric-select.filter-by">Filter by</Trans>
<IconButton
name={'info-circle'}
size="sm"
variant={'secondary'}
tooltip={
<Trans i18nKey="trails.metric-select.otel-switch">
This switch enables filtering by OTel resources for OTel native data sources.
</Trans>
}
/>
</div>
}
className={styles.displayOption}
>
<div
title={
!isStandardOtel ? 'This setting is disabled because this is not an OTel native data source.' : ''
}
>
<InlineSwitch
disabled={!isStandardOtel}
showLabel={true}
label="Otel experience"
value={useOtelExperience}
onChange={model.onToggleOtelExperience}
/>
</div>
</Field>
)}
</div>
{metricNamesError && (
<Alert title="Unable to retrieve metric names" severity="error">

View File

@ -14,13 +14,13 @@ describe('getPreviewPanelFor', () => {
}
test('When there are no filters, replace the ${filters} variable', () => {
const expected = 'avg(${metric}{__ignore_usage__=""})';
const expected = 'avg(${metric}{__ignore_usage__=""} ${otel_join_query})';
const expr = callAndGetExpr(0);
expect(expr).toStrictEqual(expected);
});
test('When there are 1 or more filters, append to the ${filters} variable', () => {
const expected = 'avg(${metric}{${filters},__ignore_usage__=""})';
const expected = 'avg(${metric}{${filters},__ignore_usage__=""} ${otel_join_query})';
for (let i = 1; i < 10; ++i) {
const expr = callAndGetExpr(1);

View File

@ -17,6 +17,8 @@ jest.mock('@grafana/runtime', () => ({
describe('TrailStore', () => {
beforeAll(() => {
jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve());
let localStore: Record<string, string> = {};
const localStorageMock = {
@ -494,6 +496,8 @@ describe('TrailStore', () => {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-deployment_environment': ['undefined'],
'var-otel_resources': [''],
'var-filters': [],
refresh: '',
},
@ -691,6 +695,8 @@ describe('TrailStore', () => {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-deployment_environment': ['undefined'],
'var-otel_resources': [''],
'var-filters': [],
refresh: '',
},
@ -702,6 +708,8 @@ describe('TrailStore', () => {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-deployment_environment': ['undefined'],
'var-otel_resources': [''],
'var-filters': [],
refresh: '',
},
@ -713,6 +721,8 @@ describe('TrailStore', () => {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-deployment_environment': ['undefined'],
'var-otel_resources': [''],
'var-filters': [],
refresh: '',
},
@ -732,6 +742,8 @@ describe('TrailStore', () => {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-deployment_environment': ['undefined'],
'var-otel_resources': [''],
'var-filters': [],
refresh: '',
},

View File

@ -1,5 +1,10 @@
import { DataSourceApi } from '@grafana/data';
import { PromMetricsMetadata, PromMetricsMetadataItem } from '@grafana/prometheus';
import {
DataSourceApi,
DataSourceGetTagKeysOptions,
DataSourceGetTagValuesOptions,
MetricFindValue,
} from '@grafana/data';
import { PrometheusDatasource, PromMetricsMetadata, PromMetricsMetadataItem, PromQuery } from '@grafana/prometheus';
import PromQlLanguageProvider from '@grafana/prometheus/src/language_provider';
import { getDataSourceSrv } from '@grafana/runtime';
@ -55,6 +60,44 @@ export class MetricDatasourceHelper {
const metadata = await this._metricsMetadata;
return metadata?.[metric];
}
/**
* Used for filtering label names for OTel resources to add custom match filters
* - target_info metric
* - deployment_environment label
* - all other OTel filters
* @param options
* @returns
*/
public async getTagKeys(options: DataSourceGetTagKeysOptions<PromQuery>): Promise<MetricFindValue[]> {
const ds = await this.getDatasource();
if (ds instanceof PrometheusDatasource) {
const keys = await ds.getTagKeys(options);
return keys;
}
return [];
}
/**
* Used for filtering label values for OTel resources to add custom match filters
* - target_info metric
* - deployment_environment label
* - all other OTel filters
* @param options
* @returns
*/
public async getTagValues(options: DataSourceGetTagValuesOptions<PromQuery>) {
const ds = await this.getDatasource();
if (ds instanceof PrometheusDatasource) {
const keys = await ds.getTagValues(options);
return keys;
}
return [];
}
}
export function getMetricDescription(metadata?: PromMetricsMetadataItem) {

View File

@ -0,0 +1,90 @@
import { RawTimeRange } from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { getOtelResources, totalOtelResources, isOtelStandardization, getDeploymentEnvironments } from './api';
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => {
return {
get: (
url: string,
params?: Record<string, string | number>,
requestId?: string,
options?: Partial<BackendSrvRequest>
) => {
if (requestId === 'explore-metrics-otel-resources') {
return Promise.resolve({ data: ['job', 'instance', 'deployment_environment'] });
} else if (requestId === 'explore-metrics-otel-check-total') {
return Promise.resolve({
data: {
result: [
{ metric: { job: 'job1', instance: 'instance1' } },
{ metric: { job: 'job2', instance: 'instance2' } },
],
},
});
} else if (requestId === 'explore-metrics-otel-check-standard') {
return Promise.resolve({
data: {
result: [{ metric: { job: 'job1', instance: 'instance1' } }],
},
});
} else if (requestId === 'explore-metrics-otel-resources-deployment-env') {
return Promise.resolve({ data: ['env1', 'env2'] });
}
return [];
},
};
},
}));
describe('OTEL API', () => {
const dataSourceUid = 'test-uid';
const timeRange: RawTimeRange = {
from: 'now-1h',
to: 'now',
};
afterAll(() => {
jest.clearAllMocks();
});
describe('getOtelResources', () => {
it('should fetch and filter OTEL resources', async () => {
const resources = await getOtelResources(dataSourceUid, timeRange);
expect(resources).toEqual(['job', 'instance']);
});
});
describe('totalOtelResources', () => {
it('should fetch total OTEL resources', async () => {
const result = await totalOtelResources(dataSourceUid, timeRange);
expect(result).toEqual({
jobs: ['job1', 'job2'],
instances: ['instance1', 'instance2'],
});
});
});
describe('isOtelStandardization', () => {
// keeping for reference because standardization for OTel by series on target_info for job&instance is not consistent
// There is a bug currently where there is stale data in Prometheus resulting in duplicate series for job&instance at random times
// When this is resolved, we can check for standardization again
xit('should check if OTEL standardization is met when there are no duplicate series on target_info for job&instance', async () => {
// will return duplicates, see mock above
const isStandard = await isOtelStandardization(dataSourceUid, timeRange);
expect(isStandard).toBe(false);
});
});
describe('getDeploymentEnvironments', () => {
it('should fetch deployment environments', async () => {
const environments = await getDeploymentEnvironments(dataSourceUid, timeRange);
expect(environments).toEqual(['env1', 'env2']);
});
});
});

View File

@ -0,0 +1,166 @@
import { RawTimeRange } from '@grafana/data';
import { getPrometheusTime } from '@grafana/prometheus/src/language_utils';
import { getBackendSrv } from '@grafana/runtime';
import { OtelResponse, LabelResponse, OtelTargetType } from './types';
const OTEL_RESOURCE_EXCLUDED_FILTERS = ['__name__', 'deployment_environment']; // name is handled by metric search metrics bar
/**
* Function used to test for OTEL
* 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)`;
export const TARGET_INFO_FILTER = { key: '__name__', value: 'target_info', operator: '=' };
/**
* Query the DS for target_info matching job and instance.
* Parse the results to get label filters.
* @param dataSourceUid
* @param timeRange
* @returns OtelResourcesType[], labels for the query result requesting matching job and instance on target_info metric
*/
export async function getOtelResources(
dataSourceUid: string,
timeRange: RawTimeRange,
excludedFilters?: string[],
matchFilters?: string
): Promise<string[]> {
const allExcludedFilters = (excludedFilters ?? []).concat(OTEL_RESOURCE_EXCLUDED_FILTERS);
const start = getPrometheusTime(timeRange.from, false);
const end = getPrometheusTime(timeRange.to, true);
const url = `/api/datasources/uid/${dataSourceUid}/resources/api/v1/labels`;
const params: Record<string, string | number> = {
start,
end,
'match[]': `{__name__="target_info"${matchFilters ? `,${matchFilters}` : ''}}`,
};
const response = await getBackendSrv().get<LabelResponse>(url, params, 'explore-metrics-otel-resources');
// exclude __name__ or deployment_environment or previously chosen filters
const resources = response.data?.filter((resource) => !allExcludedFilters.includes(resource)).map((el: string) => el);
return resources;
}
/**
* Get the total amount of job/instance pairs on target info metric
*
* @param dataSourceUid
* @param timeRange
* @param expr
* @returns
*/
export async function totalOtelResources(
dataSourceUid: string,
timeRange: RawTimeRange,
filters?: string
): Promise<OtelTargetType> {
const start = getPrometheusTime(timeRange.from, false);
const end = getPrometheusTime(timeRange.to, true);
const url = `/api/datasources/uid/${dataSourceUid}/resources/api/v1/query`;
const paramsTotalTargets: Record<string, string | number> = {
start,
end,
query: otelTargetInfoQuery(filters),
};
const responseTotal = await getBackendSrv().get<OtelResponse>(
url,
paramsTotalTargets,
'explore-metrics-otel-check-total'
);
let jobs: string[] = [];
let instances: string[] = [];
responseTotal.data.result.forEach((result) => {
// NOTE: sometimes there are target_info series with
// - both job and instance labels
// - only job label
// - only instance label
// Here we make sure both of them are present
// because we use this collection to filter metric names
if (result.metric.job && result.metric.instance) {
jobs.push(result.metric.job);
instances.push(result.metric.instance);
}
});
const otelTargets: OtelTargetType = {
jobs,
instances,
};
return otelTargets;
}
/**
* Look for duplicated series in target_info metric by job and instance labels
* If each job&instance combo is unique, the data source is otel standardized.
* If there is a count by job&instance on target_info greater than one,
* the data source is not standardized
*
* @param dataSourceUid
* @param timeRange
* @param expr
* @returns
*/
export async function isOtelStandardization(
dataSourceUid: string,
timeRange: RawTimeRange,
expr?: string
): Promise<boolean> {
const url = `/api/datasources/uid/${dataSourceUid}/resources/api/v1/query`;
const start = getPrometheusTime(timeRange.from, false);
const end = getPrometheusTime(timeRange.to, true);
const paramsTargets: Record<string, string | number> = {
start,
end,
// any data source with duplicated series will have a count > 1
query: `${otelTargetInfoQuery()} > 1`,
};
const response = await getBackendSrv().get<OtelResponse>(url, paramsTargets, 'explore-metrics-otel-check-standard');
// the response should be not greater than zero if it is standard
const checkStandard = !(response.data.result.length > 0);
return checkStandard;
}
/**
* Query the DS for deployment environment label values.
*
* @param dataSourceUid
* @param timeRange
* @returns string[], values for the deployment_environment label
*/
export async function getDeploymentEnvironments(dataSourceUid: string, timeRange: RawTimeRange): Promise<string[]> {
const start = getPrometheusTime(timeRange.from, false);
const end = getPrometheusTime(timeRange.to, true);
const url = `/api/datasources/uid/${dataSourceUid}/resources/api/v1/label/deployment_environment/values`;
const params: Record<string, string | number> = {
start,
end,
'match[]': '{__name__="target_info"}',
};
const response = await getBackendSrv().get<LabelResponse>(
url,
params,
'explore-metrics-otel-resources-deployment-env'
);
// exclude __name__ or deployment_environment or previously chosen filters
const resources = response.data;
return resources;
}

View File

@ -0,0 +1,32 @@
export type OtelResponse = {
data: {
result: [
{
metric: {
job: string;
instance: string;
};
},
];
};
status: 'success' | 'error';
error?: 'string';
warnings?: string[];
};
export type LabelResponse = {
data: string[];
status: 'success' | 'error';
error?: 'string';
warnings?: string[];
};
export type OtelTargetType = {
jobs: string[];
instances: string[];
};
export type OtelResourcesObject = {
filters: string;
labels: string;
};

View File

@ -0,0 +1,195 @@
import { MetricFindValue } from '@grafana/data';
import { AdHocFiltersVariable, CustomVariable, sceneGraph, SceneObject } from '@grafana/scenes';
import { VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_RESOURCES } from '../shared';
import { OtelResourcesObject } from './types';
export const blessedList = (): Record<string, number> => {
return {
cloud_availability_zone: 0,
cloud_region: 0,
container_name: 0,
k8s_cluster_name: 0,
k8s_container_name: 0,
k8s_cronjob_name: 0,
k8s_daemonset_name: 0,
k8s_deployment_name: 0,
k8s_job_name: 0,
k8s_namespace_name: 0,
k8s_pod_name: 0,
k8s_replicaset_name: 0,
k8s_statefulset_name: 0,
service_instance_id: 0,
service_name: 0,
service_namespace: 0,
};
};
export function sortResources(resources: MetricFindValue[], excluded: string[]) {
// these may be filtered
const promotedList = blessedList();
const blessed = Object.keys(promotedList);
resources = resources.filter((resource) => {
// if not in the list keep it
const val = (resource.value ?? '').toString();
if (!blessed.includes(val)) {
return true;
}
// remove blessed filters
// but indicate which are available
promotedList[val] = 1;
return false;
});
const promotedResources = Object.keys(promotedList)
.filter((resource) => promotedList[resource] && !excluded.includes(resource))
.map((v) => ({ text: v }));
// put the filters first
return promotedResources.concat(resources);
}
/**
* Return a collection of labels and labels filters.
* This data is used to build the join query to filter with otel resources
*
* @param otelResourcesObject
* @returns a string that is used to add a join query to filter otel resources
*/
export function getOtelJoinQuery(otelResourcesObject: OtelResourcesObject): string {
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}})`;
}
return otelResourcesJoinQuery;
}
/**
* Returns an object containing all the filters for otel resources as well as a list of labels
*
* @param scene
* @param firstQueryVal
* @returns
*/
export function getOtelResourcesObject(scene: SceneObject, firstQueryVal?: string): OtelResourcesObject {
const otelResources = sceneGraph.lookupVariable(VAR_OTEL_RESOURCES, scene);
// add deployment env to otel resource filters
const otelDepEnv = sceneGraph.lookupVariable(VAR_OTEL_DEPLOYMENT_ENV, scene);
let otelResourcesObject = { labels: '', filters: '' };
if (otelResources instanceof AdHocFiltersVariable && otelDepEnv instanceof CustomVariable) {
// get the collection of adhoc filters
const otelFilters = otelResources.state.filters;
// get the value for deployment_environment variable
let otelDepEnvValue = String(otelDepEnv.getValue());
// check if there are multiple environments
const isMulti = otelDepEnvValue.includes(',');
// start with the default label filters for deployment_environment
let op = '=';
let val = firstQueryVal ? firstQueryVal : otelDepEnvValue;
// update the filters if multiple deployment environments selected
if (isMulti) {
op = '=~';
val = val.split(',').join('|');
}
// start with the deployment environment
let allFilters = `deployment_environment${op}"${val}"`;
let allLabels = 'deployment_environment';
// add the other OTEL resource filters
for (let i = 0; i < otelFilters?.length; i++) {
const labelName = otelFilters[i].key;
const op = otelFilters[i].operator;
const labelValue = otelFilters[i].value;
allFilters += `,${labelName}${op}"${labelValue}"`;
const addLabelToGroupLeft = labelName !== 'job' && labelName !== 'instance';
if (addLabelToGroupLeft) {
allLabels += `,${labelName}`;
}
}
otelResourcesObject.labels = allLabels;
otelResourcesObject.filters = allFilters;
return otelResourcesObject;
}
return otelResourcesObject;
}
/**
* This function checks that when adding OTel job and instance filters
* to the label values request for a list of metrics,
* the total character count of the request does not exceed 2000 characters
*
* @param matchTerms __name__ and other Prom filters
* @param jobsList list of jobs in target_info
* @param instancesList list of instances in target_info
* @param missingOtelTargets flag to indicate truncated job and instance filters
* @returns
*/
export function limitOtelMatchTerms(
matchTerms: string[],
jobsList: string[],
instancesList: string[],
missingOtelTargets: boolean
): { missingOtelTargets: boolean; jobsRegex: string; instancesRegex: string } {
const charLimit = 2000;
let initialCharAmount = matchTerms.join(',').length;
// start to add values to the regex and start quote
let jobsRegex = 'job=~"';
let instancesRegex = 'instance=~"';
// iterate through the jobs and instances,
// count the chars as they are added,
// stop before the total count reaches 2000
// show a warning that there are missing OTel targets and
// the user must select more OTel resource attributes
for (let i = 0; i < jobsList.length; i++) {
// use or character for the count
const orChars = i === 0 ? 0 : 2;
// count all the characters that will go into the match terms
const checkCharAmount =
initialCharAmount +
jobsRegex.length +
jobsList[i].length +
instancesRegex.length +
instancesList[i].length +
orChars;
if (checkCharAmount <= charLimit) {
if (i === 0) {
jobsRegex += `${jobsList[i]}`;
instancesRegex += `${instancesList[i]}`;
} else {
jobsRegex += `|${jobsList[i]}`;
instancesRegex += `|${instancesList[i]}`;
}
} else {
missingOtelTargets = true;
break;
}
}
// complete the quote after values have been added
jobsRegex += '"';
instancesRegex += '"';
return {
missingOtelTargets,
jobsRegex,
instancesRegex,
};
}

View File

@ -0,0 +1,191 @@
import { MetricFindValue } from '@grafana/data';
import { sortResources, getOtelJoinQuery, blessedList, limitOtelMatchTerms } from './util';
describe('sortResources', () => {
it('should sort and filter resources correctly', () => {
const resources: MetricFindValue[] = [
{ text: 'cloud_region', value: 'cloud_region' },
{ text: 'custom_resource', value: 'custom_resource' },
];
const excluded: string[] = ['cloud_region'];
const result = sortResources(resources, excluded);
expect(result).toEqual([{ text: 'custom_resource', value: 'custom_resource' }]);
});
});
describe('getOtelJoinQuery', () => {
it('should return the correct join query', () => {
const otelResourcesObject = {
filters: 'job="test-job",instance="test-instance"',
labels: 'deployment_environment,custom_label',
};
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"})'
);
});
it('should return an empty string if filters or labels are missing', () => {
const otelResourcesObject = {
filters: '',
labels: '',
};
const result = getOtelJoinQuery(otelResourcesObject);
expect(result).toBe('');
});
});
describe('blessedList', () => {
it('should return the correct blessed list', () => {
const result = blessedList();
expect(result).toEqual({
cloud_availability_zone: 0,
cloud_region: 0,
container_name: 0,
k8s_cluster_name: 0,
k8s_container_name: 0,
k8s_cronjob_name: 0,
k8s_daemonset_name: 0,
k8s_deployment_name: 0,
k8s_job_name: 0,
k8s_namespace_name: 0,
k8s_pod_name: 0,
k8s_replicaset_name: 0,
k8s_statefulset_name: 0,
service_instance_id: 0,
service_name: 0,
service_namespace: 0,
});
});
});
describe('sortResources', () => {
it('should sort and filter resources correctly', () => {
const resources: MetricFindValue[] = [
{ text: 'cloud_region', value: 'cloud_region' },
{ text: 'custom_resource', value: 'custom_resource' },
];
const excluded: string[] = ['cloud_region'];
const result = sortResources(resources, excluded);
expect(result).toEqual([{ text: 'custom_resource', value: 'custom_resource' }]);
});
it('should promote blessed resources and exclude specified ones', () => {
const resources: MetricFindValue[] = [
{ text: 'custom_resource', value: 'custom_resource' },
{ text: 'k8s_cluster_name', value: 'k8s_cluster_name' },
];
const excluded: string[] = ['k8s_cluster_name'];
const result = sortResources(resources, excluded);
expect(result).toEqual([{ text: 'custom_resource', value: 'custom_resource' }]);
});
});
describe('getOtelJoinQuery', () => {
it('should return the correct join query', () => {
const otelResourcesObject = {
filters: 'job="test-job",instance="test-instance"',
labels: 'deployment_environment,custom_label',
};
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"})'
);
});
it('should return an empty string if filters or labels are missing', () => {
const otelResourcesObject = {
filters: '',
labels: '',
};
const result = getOtelJoinQuery(otelResourcesObject);
expect(result).toBe('');
});
});
describe('limitOtelMatchTerms', () => {
it('should limit the OTel match terms if the total match term character count exceeds 2000', () => {
// the initial match is 1980 characters
const promMatchTerms: string[] = [
`${[...Array(1979).keys()]
.map((el) => {
return '0';
})
.join('')}"`,
];
// job=~"" is 7 chars
// instance=~"" is 12 characters
// 7 + 12 + 1979 = 1998
// so we have room to add 2 more characters
// attribute values that are b will be left out
const jobs = ['a', 'b', 'c'];
const instances = ['d', 'e', 'f'];
const missingOtelTargets = false;
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances, missingOtelTargets);
expect(result.missingOtelTargets).toEqual(true);
expect(result.jobsRegex).toEqual('job=~"a"');
expect(result.instancesRegex).toEqual('instance=~"d"');
});
it('should include | char in the count', () => {
// the initial match is 1980 characters
const promMatchTerms: string[] = [
`${[...Array(1975).keys()]
.map((el) => {
return '0';
})
.join('')}"`,
];
// job=~"" is 7 chars
// instance=~"" is 12 characters
// 7 + 12 + 1975 = 1994
// so we have room to add 6 more characters
// the extra 6 characters will be 'a|b' and 'd|e'
const jobs = ['a', 'b', 'c'];
const instances = ['d', 'e', 'f'];
const missingOtelTargets = false;
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances, missingOtelTargets);
expect(result.missingOtelTargets).toEqual(true);
expect(result.jobsRegex).toEqual('job=~"a|b"');
expect(result.instancesRegex).toEqual('instance=~"d|e"');
});
it('should add all OTel job and instance matches if the character count is less that 2000', () => {
const promMatchTerms: string[] = [];
const jobs = ['job1', 'job2', 'job3', 'job4', 'job5'];
const instances = ['instance1', 'instance2', 'instance3', 'instance4', 'instance5'];
const missingOtelTargets = false;
const result = limitOtelMatchTerms(promMatchTerms, jobs, instances, missingOtelTargets);
expect(result.missingOtelTargets).toEqual(false);
expect(result.jobsRegex).toEqual('job=~"job1|job2|job3|job4|job5"');
expect(result.instancesRegex).toEqual('instance=~"instance1|instance2|instance3|instance4|instance5"');
});
});

View File

@ -23,6 +23,12 @@ export const VAR_DATASOURCE = 'ds';
export const VAR_DATASOURCE_EXPR = '${ds}';
export const VAR_LOGS_DATASOURCE = 'logsDs';
export const VAR_LOGS_DATASOURCE_EXPR = '${logsDs}';
export const VAR_OTEL_RESOURCES = 'otel_resources';
export const VAR_OTEL_RESOURCES_EXPR = '${otel_resources}';
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 LOGS_METRIC = '$__logs__';
export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query';
@ -48,6 +54,16 @@ export function getVariablesWithMetricConstant(metric: string) {
];
}
export function getVariablesWithOtelJoinQueryConstant(otelJoinQuery: string) {
return [
new ConstantVariable({
name: VAR_OTEL_JOIN_QUERY,
value: otelJoinQuery,
hide: VariableHide.hideVariable,
}),
];
}
export class MetricSelectedEvent extends BusEventWithPayload<string | undefined> {
public static type = 'metric-selected-event';
}

View File

@ -2593,11 +2593,20 @@
"trails": {
"metric-overview": {
"description-label": "Description",
"labels-label": "Labels",
"labels": "Labels",
"metric-attributes": "Metric attributes",
"no-description": "No description available",
"type-label": "Type",
"unit-label": "Unit",
"unknown-type": "Unknown"
},
"metric-select": {
"filter-by": "Filter by",
"otel-switch": "This switch enables filtering by OTel resources for OTel native data sources."
},
"settings": {
"always-keep-selected-metric-graph-in-view": "Always keep selected metric graph in-view",
"show-previews-of-metric-graphs": "Show previews of metric graphs"
}
},
"transformations": {

View File

@ -2593,11 +2593,20 @@
"trails": {
"metric-overview": {
"description-label": "Đęşčřįpŧįőʼn",
"labels-label": "Ŀäþęľş",
"labels": "Ŀäþęľş",
"metric-attributes": "Męŧřįč äŧŧřįþūŧęş",
"no-description": "Ńő đęşčřįpŧįőʼn äväįľäþľę",
"type-label": "Ŧypę",
"unit-label": "Ůʼnįŧ",
"unknown-type": "Ůʼnĸʼnőŵʼn"
},
"metric-select": {
"filter-by": "Fįľŧęř þy",
"otel-switch": "Ŧĥįş şŵįŧčĥ ęʼnäþľęş ƒįľŧęřįʼnģ þy ØŦęľ řęşőūřčęş ƒőř ØŦęľ ʼnäŧįvę đäŧä şőūřčęş."
},
"settings": {
"always-keep-selected-metric-graph-in-view": "Åľŵäyş ĸęęp şęľęčŧęđ męŧřįč ģřäpĥ įʼn-vįęŵ",
"show-previews-of-metric-graphs": "Ŝĥőŵ přęvįęŵş őƒ męŧřįč ģřäpĥş"
}
},
"transformations": {