From 4d1adf9db47e7eaee26d40e4bef203c1f6b3abb0 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Thu, 19 Sep 2024 09:34:31 -0500 Subject: [PATCH] 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 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 --- .betterer.results | 3 +- .../trails/ActionTabs/BreakdownScene.tsx | 4 +- .../trails/ActionTabs/MetricOverviewScene.tsx | 8 +- .../AutoQueryEngine.test.ts | 138 +++--- .../query-generators/common/baseQuery.ts | 6 +- .../query-generators/histogram.ts | 6 +- public/app/features/trails/DataTrail.test.tsx | 92 +++- public/app/features/trails/DataTrail.tsx | 407 +++++++++++++++++- .../app/features/trails/DataTrailSettings.tsx | 35 +- .../app/features/trails/DataTrailsHistory.tsx | 80 +++- .../trails/MetricSelect/MetricSelectScene.tsx | 131 +++++- .../trails/MetricSelect/previewPanel.test.ts | 4 +- .../trails/TrailStore/TrailStore.test.ts | 12 + .../trails/helpers/MetricDatasourceHelper.ts | 47 +- public/app/features/trails/otel/api.test.ts | 90 ++++ public/app/features/trails/otel/api.ts | 166 +++++++ public/app/features/trails/otel/types.ts | 32 ++ public/app/features/trails/otel/util.ts | 195 +++++++++ public/app/features/trails/otel/utils.test.ts | 191 ++++++++ public/app/features/trails/shared.ts | 16 + public/locales/en-US/grafana.json | 11 +- public/locales/pseudo-LOCALE/grafana.json | 11 +- 22 files changed, 1572 insertions(+), 113 deletions(-) create mode 100644 public/app/features/trails/otel/api.test.ts create mode 100644 public/app/features/trails/otel/api.ts create mode 100644 public/app/features/trails/otel/types.ts create mode 100644 public/app/features/trails/otel/util.ts create mode 100644 public/app/features/trails/otel/utils.test.ts diff --git a/.betterer.results b/.betterer.results index 8858cfe8c29e..6d1ccb5383cf 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5279,8 +5279,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], "public/app/features/trails/DataTrailSettings.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/trails/DataTrailsHistory.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] diff --git a/public/app/features/trails/ActionTabs/BreakdownScene.tsx b/public/app/features/trails/ActionTabs/BreakdownScene.tsx index d5b742308d01..c688f2fe106e 100644 --- a/public/app/features/trails/ActionTabs/BreakdownScene.tsx +++ b/public/app/features/trails/ActionTabs/BreakdownScene.tsx @@ -211,13 +211,15 @@ export class BreakdownScene extends SceneObjectBase { const { labels, body, loading, value, blockingMessage } = model.useState(); const styles = useStyles2(getStyles); + const { useOtelExperience } = getTrailFor(model).useState(); + return (
{!loading && labels.length && (
- +
diff --git a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx index b7894ca6d865..ee08264b417a 100644 --- a/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx +++ b/public/app/features/trails/ActionTabs/MetricOverviewScene.tsx @@ -68,6 +68,8 @@ export class MetricOverviewScene extends SceneObjectBase - Labels + {useOtelExperience ? ( + Metric attributes + ) : ( + Labels + )} {labelOptions.length === 0 && 'Unable to fetch labels.'} {labelOptions.map((l) => ( diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts index b8176b51c37a..f9bda26a4aa2 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts +++ b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.test.ts @@ -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(); @@ -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); diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/baseQuery.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/baseQuery.ts index d018b288a3a0..5d0759e669d2 100644 --- a/public/app/features/trails/AutomaticMetricQueries/query-generators/common/baseQuery.ts +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/common/baseQuery.ts @@ -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}`; } diff --git a/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts index 2a12b285ad7f..4fd492d5322b 100644 --- a/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts +++ b/public/app/features/trails/AutomaticMetricQueries/query-generators/histogram.ts @@ -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]; diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index b8542fb8d0dc..58c42c65d5eb 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -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'); + }); + }); }); diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 54c08d11f2f6..b6bffb2402c9 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -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=~"|"", instance=~"|" + 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 { public constructor(state: Partial) { 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 { 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 { } 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 { 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) => { - 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 (
{showHeaderForFirstTimeUsers && } @@ -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 ?? ''), ], }); } diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx index 4b9a4e7ee0a0..2584b7bb930d 100644 --- a/public/app/features/trails/DataTrailSettings.tsx +++ b/public/app/features/trails/DataTrailSettings.tsx @@ -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 { this.setState({ isOpen }); }; + public onTogglePreviews = () => { + const trail = getTrailFor(this); + trail.setState({ showPreviews: !trail.state.showPreviews }); + }; + static Component = ({ model }: SceneComponentProps) => { 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 */
evt.stopPropagation()}>
Settings
-
-
Always keep selected metric graph in-view
- -
+ {topScene instanceof MetricScene && ( +
+
+ + Always keep selected metric graph in-view + +
+ +
+ )} + {topScene instanceof MetricSelectScene && ( +
+
+ Show previews of metric graphs +
+ +
+ )}
); }; diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index 322cbca0b55d..26b3d6b4e058 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -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 = { 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 { public constructor(state: Partial) { - 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 { 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 { 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 { 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')), }, }; } diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index b629f1f9b7ea..f6a5001689e3 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -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 i constructor(state: Partial) { super({ - showPreviews: true, $variables: state.$variables, metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL, body: @@ -182,6 +181,34 @@ export class MetricSelectScene extends SceneObjectBase 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 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 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 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 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 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 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 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 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) => { - 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 i ]} /> - + {hasOtelResources && ( + + Filter by + + This switch enables filtering by OTel resources for OTel native data sources. + + } + /> +
+ } + className={styles.displayOption} + > +
+ +
+ + )}
{metricNamesError && ( diff --git a/public/app/features/trails/MetricSelect/previewPanel.test.ts b/public/app/features/trails/MetricSelect/previewPanel.test.ts index a0c3c9d620e3..59daafa32976 100644 --- a/public/app/features/trails/MetricSelect/previewPanel.test.ts +++ b/public/app/features/trails/MetricSelect/previewPanel.test.ts @@ -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); diff --git a/public/app/features/trails/TrailStore/TrailStore.test.ts b/public/app/features/trails/TrailStore/TrailStore.test.ts index 0d011d60afb8..703793318826 100644 --- a/public/app/features/trails/TrailStore/TrailStore.test.ts +++ b/public/app/features/trails/TrailStore/TrailStore.test.ts @@ -17,6 +17,8 @@ jest.mock('@grafana/runtime', () => ({ describe('TrailStore', () => { beforeAll(() => { + jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); + let localStore: Record = {}; 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: '', }, diff --git a/public/app/features/trails/helpers/MetricDatasourceHelper.ts b/public/app/features/trails/helpers/MetricDatasourceHelper.ts index d293add4e10e..c1e25cfbaed9 100644 --- a/public/app/features/trails/helpers/MetricDatasourceHelper.ts +++ b/public/app/features/trails/helpers/MetricDatasourceHelper.ts @@ -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): Promise { + 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) { + const ds = await this.getDatasource(); + + if (ds instanceof PrometheusDatasource) { + const keys = await ds.getTagValues(options); + return keys; + } + + return []; + } } export function getMetricDescription(metadata?: PromMetricsMetadataItem) { diff --git a/public/app/features/trails/otel/api.test.ts b/public/app/features/trails/otel/api.test.ts new file mode 100644 index 000000000000..d0189b79c2b0 --- /dev/null +++ b/public/app/features/trails/otel/api.test.ts @@ -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, + requestId?: string, + options?: Partial + ) => { + 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']); + }); + }); +}); diff --git a/public/app/features/trails/otel/api.ts b/public/app/features/trails/otel/api.ts new file mode 100644 index 000000000000..f0e4ca98fe12 --- /dev/null +++ b/public/app/features/trails/otel/api.ts @@ -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 { + 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 = { + start, + end, + 'match[]': `{__name__="target_info"${matchFilters ? `,${matchFilters}` : ''}}`, + }; + + const response = await getBackendSrv().get(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 { + 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 = { + start, + end, + query: otelTargetInfoQuery(filters), + }; + + const responseTotal = await getBackendSrv().get( + 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 { + 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 = { + start, + end, + // any data source with duplicated series will have a count > 1 + query: `${otelTargetInfoQuery()} > 1`, + }; + + const response = await getBackendSrv().get(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 { + 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 = { + start, + end, + 'match[]': '{__name__="target_info"}', + }; + + const response = await getBackendSrv().get( + url, + params, + 'explore-metrics-otel-resources-deployment-env' + ); + + // exclude __name__ or deployment_environment or previously chosen filters + const resources = response.data; + + return resources; +} diff --git a/public/app/features/trails/otel/types.ts b/public/app/features/trails/otel/types.ts new file mode 100644 index 000000000000..64cf4d7c1b46 --- /dev/null +++ b/public/app/features/trails/otel/types.ts @@ -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; +}; diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts new file mode 100644 index 000000000000..0734355aebf5 --- /dev/null +++ b/public/app/features/trails/otel/util.ts @@ -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 => { + 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, + }; +} diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts new file mode 100644 index 000000000000..39fbf12af7a0 --- /dev/null +++ b/public/app/features/trails/otel/utils.test.ts @@ -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"'); + }); +}); diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 8da3b78f63c5..8399b6ffb36a 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -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 { public static type = 'metric-selected-event'; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 29529fcd98ad..74905ac05777 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index dd3a9a9f27cd..1107f3bdda24 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": {