diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5659ac3d70a..4b8a03abd9d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -228,5 +228,6 @@ export interface FeatureToggles { azureMonitorDisableLogLimit?: boolean; dashboardSchemaV2?: boolean; playlistsWatcher?: boolean; + exploreMetricsRelatedLogs?: boolean; enableExtensionsAdminPage?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cfcc24edd7e..66a5c1d76b9 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1570,6 +1570,14 @@ var ( Owner: grafanaAppPlatformSquad, RequiresRestart: true, }, + { + Name: "exploreMetricsRelatedLogs", + Description: "Display Related Logs in Explore Metrics", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityMetricsSquad, + FrontendOnly: true, + HideFromDocs: true, + }, { Name: "enableExtensionsAdminPage", Description: "Enables the extension admin page regardless of development mode", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d9b8866677b..003d2be0097 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -209,4 +209,5 @@ prometheusUsesCombobox,experimental,@grafana/observability-metrics,false,false,f azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false +exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 21a69174c78..4013b71ca0c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -847,6 +847,10 @@ const ( // Enables experimental watcher for playlists FlagPlaylistsWatcher = "playlistsWatcher" + // FlagExploreMetricsRelatedLogs + // Display Related Logs in Explore Metrics + FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs" + // FlagEnableExtensionsAdminPage // Enables the extension admin page regardless of development mode FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 90efd2f74c8..5e9f6020e3d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1302,6 +1302,20 @@ "expression": "true" } }, + { + "metadata": { + "name": "exploreMetricsRelatedLogs", + "resourceVersion": "1730125602673", + "creationTimestamp": "2024-10-28T14:26:42Z" + }, + "spec": { + "description": "Display Related Logs in Explore Metrics", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "expressionParser", diff --git a/public/app/features/dashboard/components/GenAI/utils.test.ts b/public/app/features/dashboard/components/GenAI/utils.test.ts index 39741a10a2b..5707b1c3195 100644 --- a/public/app/features/dashboard/components/GenAI/utils.test.ts +++ b/public/app/features/dashboard/components/GenAI/utils.test.ts @@ -8,6 +8,7 @@ import { getDashboardChanges, getPanelStrings, isLLMPluginEnabled, sanitizeReply // Mock the llms.openai module jest.mock('@grafana/experimental', () => ({ + ...jest.requireActual('@grafana/experimental'), llms: { openai: { streamChatCompletions: jest.fn(), diff --git a/public/app/features/trails/ActionTabs/LogsScene.tsx b/public/app/features/trails/ActionTabs/LogsScene.tsx deleted file mode 100644 index fbd7151a958..00000000000 --- a/public/app/features/trails/ActionTabs/LogsScene.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - DataSourceVariable, - PanelBuilders, - SceneComponentProps, - SceneFlexItem, - SceneFlexLayout, - SceneObject, - SceneObjectBase, - SceneObjectState, - SceneQueryRunner, - SceneVariableSet, - VariableValueSelectors, -} from '@grafana/scenes'; -import { Stack } from '@grafana/ui'; - -import { SelectMetricAction } from '../MetricSelect/SelectMetricAction'; -import { LOGS_METRIC, VAR_LOGS_DATASOURCE, VAR_LOGS_DATASOURCE_EXPR } from '../shared'; - -interface LogsSceneState extends SceneObjectState { - initialDS?: string; - controls: SceneObject[]; - body: SceneFlexLayout; -} - -export class LogsScene extends SceneObjectBase { - public constructor(state: Partial) { - const logsQuery = new SceneQueryRunner({ - datasource: { uid: VAR_LOGS_DATASOURCE_EXPR }, - queries: [ - { - refId: 'A', - expr: '{${filters}} | logfmt', - }, - ], - }); - - super({ - $variables: state.$variables ?? getVariableSet(state.initialDS), - controls: state.controls ?? [new VariableValueSelectors({ layout: 'vertical' })], - body: - state.body ?? - new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - body: PanelBuilders.logs() - .setTitle('Logs') - .setData(logsQuery) - .setHeaderActions(new SelectMetricAction({ metric: LOGS_METRIC, title: 'Open' })) - .build(), - }), - ], - }), - ...state, - }); - } - - static Component = ({ model }: SceneComponentProps) => { - const { controls, body } = model.useState(); - - return ( - - {controls && ( - - {controls.map((control) => ( - - ))} - - )} - - - ); - }; -} - -function getVariableSet(initialDS?: string) { - return new SceneVariableSet({ - variables: [ - new DataSourceVariable({ - name: VAR_LOGS_DATASOURCE, - label: 'Logs data source', - value: initialDS, - pluginId: 'loki', - }), - ], - }); -} - -export function buildLogsScene() { - return new SceneFlexItem({ - body: new LogsScene({}), - }); -} diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index 8c6c3012fde..ccf7a0af877 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -4,6 +4,7 @@ import { isNumber, max, min, throttle } from 'lodash'; import { useEffect } from 'react'; import { DataFrame, FieldType, GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { ConstantVariable, PanelBuilders, @@ -33,6 +34,7 @@ import { AutoQueryDef } from '../AutomaticMetricQueries/types'; import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; import { DataTrail } from '../DataTrail'; import { MetricScene } from '../MetricScene'; +import { RelatedLogsScene } from '../RelatedLogs/RelatedLogsScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; import { updateOtelJoinWithGroupLeft } from '../otel/util'; @@ -58,6 +60,7 @@ import { getLabelOptions } from './utils'; import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior'; const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60; +const relatedLogsFeatureEnabled = config.featureToggles.exploreMetricsRelatedLogs; export interface LabelBreakdownSceneState extends SceneObjectState { body?: LayoutSwitcher; @@ -334,6 +337,8 @@ export class LabelBreakdownScene extends SceneObjectBase @@ -359,6 +364,7 @@ export class LabelBreakdownScene extends SceneObjectBase
{body && }
+ {relatedLogsFeatureEnabled && }
); diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 65c72aef90b..b924fba3740 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -69,7 +69,7 @@ export interface DataTrailState extends SceneObjectState { settings: DataTrailSettings; createdAt: number; - // just for for the starting data source + // just for the starting data source initialDS?: string; initialFilters?: AdHocVariableFilter[]; @@ -395,7 +395,7 @@ export class DataTrail extends SceneObjectBase { } /** * 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 @@ -409,14 +409,14 @@ export class DataTrail extends SceneObjectBase { * This function is called on start and when variables change. * On start will provide the deploymentEnvironments and hasOtelResources parameters. * In the variable change case, we will not provide these parameters. It is assumed that the - * data source has been checked for otel resources and standardization and the otel variables are enabled at this point. - * @param datasourceUid - * @param timeRange - * @param otelDepEnvVariable - * @param otelResourcesVariable - * @param otelJoinQueryVariable - * @param deploymentEnvironments - * @param hasOtelResources + * data source has been checked for otel resources and standardization and the otel variables are enabled at this point. + * @param datasourceUid + * @param timeRange + * @param otelDepEnvVariable + * @param otelResourcesVariable + * @param otelJoinQueryVariable + * @param deploymentEnvironments + * @param hasOtelResources */ async updateOtelData( datasourceUid: string, diff --git a/public/app/features/trails/Integrations/logsIntegration.test.ts b/public/app/features/trails/Integrations/logsIntegration.test.ts new file mode 100644 index 00000000000..3ec65da4099 --- /dev/null +++ b/public/app/features/trails/Integrations/logsIntegration.test.ts @@ -0,0 +1,311 @@ +import { of } from 'rxjs'; + +import type { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; +import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import * as runtime from '@grafana/runtime'; + +import { + extractRecordingRulesFromRuleGroups, + fetchAndExtractLokiRecordingRules, + getLokiQueryForRelatedMetric, + getDataSourcesWithRecordingRulesContainingMetric, + type ExtractedRecordingRules, + type RecordingRuleGroup, +} from './logsIntegration'; + +const mockLokiDS1: DataSourceInstanceSettings = { + access: 'proxy', + id: 1, + uid: 'loki1', + name: 'Loki Main', + type: 'loki', + url: '', + jsonData: {}, + meta: { + ...getMockPlugin(), + id: 'loki', + }, + readOnly: false, + isDefault: false, + database: '', + withCredentials: false, +}; + +const mockLokiDS2: DataSourceInstanceSettings = { + ...mockLokiDS1, + id: 2, + uid: 'loki2', + name: 'Loki Secondary', +}; + +const mockLokiDS3: DataSourceInstanceSettings = { + ...mockLokiDS1, + id: 3, + uid: 'loki3', + name: 'Loki the Third with same rules', +}; + +const mockLokiDSs = [mockLokiDS1, mockLokiDS2, mockLokiDS3]; + +const mockRuleGroups1: RecordingRuleGroup[] = [ + { + name: 'group1', + rules: [ + { + name: 'metric_a_total', + query: 'sum(rate({app="app-A"} |= "error" [5m]))', + type: 'recording', + }, + { + name: 'metric_b_total', + query: 'sum(rate({app="app-B"} |= "warn" [5m]))', + type: 'recording', + }, + ], + }, +]; + +const mockRuleGroups2: RecordingRuleGroup[] = [ + { + name: 'group2', + rules: [ + { + name: 'metric_a_total', // Intentionally same name as in DS1 + query: 'sum(rate({app="app-C"} |= "error" [5m]))', + type: 'recording', + }, + ], + }, +]; + +const mockRuleGroupsWithSameRuleName: RecordingRuleGroup[] = [ + { + name: 'group_with_same_rule_names', + rules: [ + { + name: 'metric_xx_total', + query: 'sum(rate({app="app-XX"} |= "error" [5m]))', + type: 'recording', + labels: { + customLabel: 'label value 5m', + }, + }, + { + name: 'metric_xx_total', + query: 'sum(rate({app="app-YY"} |= "warn" [10m]))', + type: 'recording', + labels: { + customLabel: 'label value 10m', + }, + }, + ], + }, +]; + +const mockExtractedRules: ExtractedRecordingRules = { + loki1: [ + { + name: 'metric_a_total', + query: 'sum(rate({app="app-A"} |= "error" [5m]))', + type: 'recording', + datasource: { name: 'Loki Main', uid: 'loki1' }, + hasMultipleOccurrences: false, + }, + { + name: 'metric_b_total', + query: 'sum(rate({app="app-B"} |= "warn" [5m]))', + type: 'recording', + datasource: { name: 'Loki Main', uid: 'loki1' }, + hasMultipleOccurrences: false, + }, + ], + loki2: [ + { + name: 'metric_a_total', + query: 'sum(rate({app="app-C"} |= "error" [5m]))', + type: 'recording', + datasource: { name: 'Loki Secondary', uid: 'loki2' }, + hasMultipleOccurrences: false, + }, + ], + loki3: [ + { + name: 'metric_xx_total', + query: 'sum(rate({app="app-XX"} |= "error" [5m]))', + type: 'recording', + datasource: { name: 'Loki the Third with same rules', uid: 'loki3' }, + hasMultipleOccurrences: true, + }, + ], +}; + +// Create spy functions +const getListSpy = jest.fn().mockReturnValue(mockLokiDSs); +const fetchSpy = jest.fn().mockImplementation((req) => { + if (req.url.includes('loki1')) { + return of({ + data: { data: { groups: mockRuleGroups1 } }, + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: req.url, + config: { url: req.url }, + } as runtime.FetchResponse); + } + if (req.url.includes('loki2')) { + return of({ + data: { data: { groups: mockRuleGroups2 } }, + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: req.url, + config: { url: req.url }, + } as runtime.FetchResponse); + } + if (req.url.includes('loki3')) { + return of({ + data: { data: { groups: mockRuleGroupsWithSameRuleName } }, + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: req.url, + config: { url: req.url }, + } as runtime.FetchResponse); + } + return of({ + data: { data: { groups: [] } }, + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: req.url, + config: { url: req.url }, + } as runtime.FetchResponse); +}); + +// Mock the entire @grafana/runtime module +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: getListSpy, + get: jest.fn(), + getInstanceSettings: jest.fn(), + reload: jest.fn(), + }), + getBackendSrv: () => ({ + fetch: fetchSpy, + delete: jest.fn(), + get: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + put: jest.fn(), + request: jest.fn(), + datasourceRequest: jest.fn(), + }), +})); + +describe('Logs Integration', () => { + describe('fetchAndExtractLokiRecordingRules', () => { + beforeEach(() => { + getListSpy.mockClear(); + fetchSpy.mockClear(); + }); + + it('should fetch and extract rules from all Loki data sources', async () => { + const result = await fetchAndExtractLokiRecordingRules(); + + expect(result).toEqual(mockExtractedRules); + expect(getListSpy).toHaveBeenCalledWith({ logs: true }); + expect(fetchSpy).toHaveBeenCalledTimes(mockLokiDSs.length); + }); + + it('should handle errors from individual data sources gracefully', async () => { + // Mock console.error to avoid test output pollution + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fetchSpy.mockImplementation((req) => { + if (req.url.includes('loki1')) { + return of({ + data: { data: { groups: mockRuleGroups1 } }, + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + redirected: false, + type: 'basic', + url: req.url, + config: { url: req.url }, + } as runtime.FetchResponse); + } + throw new Error('Failed to fetch'); + }); + + const result = await fetchAndExtractLokiRecordingRules(); + + // Should still have results from the first datasource + expect(result).toHaveProperty('loki1'); + expect(result.loki1).toHaveLength(2); + expect(result.loki2).toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('getLokiQueryForRelatedMetric', () => { + it('should return the expected Loki query for a given metric', () => { + const result = getLokiQueryForRelatedMetric('metric_a_total', 'loki1', mockExtractedRules); + expect(result).toBe('{app="app-A"} |= "error"'); + }); + + it('should return empty string for non-existent data source', () => { + const result = getLokiQueryForRelatedMetric('metric_a_total', 'non-existent', mockExtractedRules); + expect(result).toBe(''); + }); + + it('should return empty string for non-existent metric', () => { + const result = getLokiQueryForRelatedMetric('non_existent_metric', 'loki1', mockExtractedRules); + expect(result).toBe(''); + }); + }); + + describe('getDataSourcesWithRecordingRulesContainingMetric', () => { + it('should find all data sources containing recording rules that define the metric of interest', () => { + const result = getDataSourcesWithRecordingRulesContainingMetric('metric_a_total', mockExtractedRules); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ name: 'Loki Main', uid: 'loki1' }); + expect(result).toContainEqual({ name: 'Loki Secondary', uid: 'loki2' }); + }); + + it('should return empty array for non-existent metric', () => { + const result = getDataSourcesWithRecordingRulesContainingMetric('non_existent_metric', mockExtractedRules); + expect(result).toHaveLength(0); + }); + + it('should find single data source for unique metric', () => { + const result = getDataSourcesWithRecordingRulesContainingMetric('metric_b_total', mockExtractedRules); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ name: 'Loki Main', uid: 'loki1' }); + }); + }); +}); + +describe('extractRecordingRulesFromRuleGroups', () => { + it('should extract only the first rule from a rule group with same rule names', () => { + const result = extractRecordingRulesFromRuleGroups(mockRuleGroupsWithSameRuleName, mockLokiDS3); + + expect(result.length).toEqual(1); + expect(result).toEqual(mockExtractedRules.loki3); + }); +}); diff --git a/public/app/features/trails/Integrations/logsIntegration.ts b/public/app/features/trails/Integrations/logsIntegration.ts new file mode 100644 index 00000000000..f86e685bbb1 --- /dev/null +++ b/public/app/features/trails/Integrations/logsIntegration.ts @@ -0,0 +1,169 @@ +import { lastValueFrom } from 'rxjs'; + +import type { DataSourceInstanceSettings, DataSourceJsonData, DataSourceSettings } from '@grafana/data'; +import { getBackendSrv, getDataSourceSrv, type BackendSrvRequest, type FetchResponse } from '@grafana/runtime'; +import { getLogQueryFromMetricsQuery } from 'app/plugins/datasource/loki/queryUtils'; + +export type RecordingRuleGroup = { + name: string; + rules: RecordingRule[]; +}; + +export type RecordingRule = { + name: string; + query: string; + type: 'recording' | 'alerting' | string; + labels?: Record; +}; + +export type FoundLokiDataSource = Pick; +export type ExtractedRecordingRule = RecordingRule & { + datasource: FoundLokiDataSource; + hasMultipleOccurrences?: boolean; +}; +export type ExtractedRecordingRules = { + [dataSourceUID: string]: ExtractedRecordingRule[]; +}; + +/** + * Fetch Loki recording rule groups from the specified datasource. + * + * @param datasourceSettings - The settings of the datasource instance. + * @returns A promise that resolves to an array of recording rule groups. + */ +async function fetchRecordingRuleGroups(datasourceSettings: DataSourceInstanceSettings) { + const recordingRuleUrl = `api/prometheus/${datasourceSettings.uid}/api/v1/rules`; + const recordingRules: BackendSrvRequest = { url: recordingRuleUrl }; + const { data } = await lastValueFrom< + FetchResponse<{ + data: { groups: RecordingRuleGroup[] }; + }> + >(getBackendSrv().fetch(recordingRules)); + + return data.data.groups; +} + +/** + * Extract recording rules from the provided rule groups and associate them with the given data source. + * + * @param ruleGroups - An array of recording rule groups to extract rules from. + * @param ds - The data source instance settings to associate with the extracted rules. + * @returns An array of extracted recording rules, each associated with the provided data source. + */ +export function extractRecordingRulesFromRuleGroups( + ruleGroups: RecordingRuleGroup[], + ds: DataSourceInstanceSettings +): ExtractedRecordingRule[] { + if (ruleGroups.length === 0) { + return []; + } + + // We only want to return the first matching rule when there are multiple rules with same name + const extractedRules = new Map(); + // const extractedRules: [] = []; + ruleGroups.forEach((rg) => { + rg.rules + .filter((r) => r.type === 'recording') + .forEach(({ type, name, query }) => { + const isExist = extractedRules.has(name); + if (isExist) { + // We already have the rule. + const existingRule = extractedRules.get(name); + if (existingRule) { + existingRule.hasMultipleOccurrences = true; + extractedRules.set(name, existingRule); + } + } else { + extractedRules.set(name, { + type, + name, + query, + datasource: { + name: ds.name, + uid: ds.uid, + }, + hasMultipleOccurrences: false, + }); + } + }); + }); + + return Array.from(extractedRules.values()); +} + +/** + * Retrieve an array of Loki data sources that contain recording rules with the specified metric name. + * + * @param metricName - The name of the metric to search for within the recording rules. + * @param extractedRecordingRules - An object containing extracted recording rules, where each key is a string and the value is an array of recording rules. + * @returns An array of `FoundLokiDataSource` objects that contain recording rules with the specified metric name. + */ +export function getDataSourcesWithRecordingRulesContainingMetric( + metricName: string, + extractedRecordingRules: ExtractedRecordingRules +): FoundLokiDataSource[] { + const foundLokiDataSources: FoundLokiDataSource[] = []; + Object.values(extractedRecordingRules).forEach((recRules) => { + recRules + .filter((rr) => rr.name === metricName) + .forEach((rr) => { + foundLokiDataSources.push(rr.datasource); + }); + }); + + return foundLokiDataSources; +} + +/** + * Generate a Loki query string for a related metric based on the provided metric name, data source ID, + * and extracted recording rules. + * + * @param metricName - The name of the metric for which to generate the Loki query. + * @param dataSourceUid - The UID of the data source containing the recording rules. + * @param extractedRecordingRules - An object containing recording rules, indexed by data source UID. + * @returns The generated Loki query string, or an empty string if the data source UID or metric name is not found. + */ +export function getLokiQueryForRelatedMetric( + metricName: string, + dataSourceUid: string, + extractedRecordingRules: ExtractedRecordingRules +): string { + if (!dataSourceUid || !extractedRecordingRules[dataSourceUid]) { + return ''; + } + const targetRule = extractedRecordingRules[dataSourceUid].find((rule) => rule.name === metricName); + if (!targetRule) { + return ''; + } + const lokiQuery = getLogQueryFromMetricsQuery(targetRule.query); + + return lokiQuery; +} + +/** + * Fetch and extract Loki recording rules from all Loki data sources. + * + * @returns {Promise} A promise that resolves to an object containing + * the extracted recording rules, keyed by data source UID. + * + * @throws Will log an error to the console if fetching or extracting rules fails for any data source. + */ +export async function fetchAndExtractLokiRecordingRules() { + const lokiDataSources = getDataSourceSrv() + .getList({ logs: true }) + .filter((ds) => ds.type === 'loki'); + const extractedRecordingRules: ExtractedRecordingRules = {}; + await Promise.all( + lokiDataSources.map(async (dataSource) => { + try { + const ruleGroups: RecordingRuleGroup[] = await fetchRecordingRuleGroups(dataSource); + const extractedRules = extractRecordingRulesFromRuleGroups(ruleGroups, dataSource); + extractedRecordingRules[dataSource.uid] = extractedRules; + } catch (err) { + console.error(err); + } + }) + ); + + return extractedRecordingRules; +} diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index 7fb329c89b9..b02e716902f 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -22,6 +22,7 @@ import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngin import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types'; import { buildLabelBreakdownActionScene } from './Breakdown/LabelBreakdownScene'; import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; +import { buildRelatedLogsScene } from './RelatedLogs/RelatedLogsScene'; import { ShareTrailButton } from './ShareTrailButton'; import { useBookmarkState } from './TrailStore/useBookmarkState'; import { reportExploreMetrics } from './interactions'; @@ -37,6 +38,8 @@ import { } from './shared'; import { getDataSource, getTrailFor, getUrlForTrail } from './utils'; +const relatedLogsFeatureEnabled = config.featureToggles.exploreMetricsRelatedLogs; + export interface MetricSceneState extends SceneObjectState { body: MetricGraphScene; metric: string; @@ -119,6 +122,15 @@ const actionViewsDefinitions: ActionViewDefinition[] = [ }, ]; +if (relatedLogsFeatureEnabled) { + actionViewsDefinitions.push({ + displayName: 'Related logs', + value: 'related-logs', + getScene: buildRelatedLogsScene, + description: 'Relevant logs based on current label filters and time range', + }); +} + export interface MetricActionBarState extends SceneObjectState {} export class MetricActionBar extends SceneObjectBase { diff --git a/public/app/features/trails/RelatedLogs/NoRelatedLogsFoundScene.tsx b/public/app/features/trails/RelatedLogs/NoRelatedLogsFoundScene.tsx new file mode 100644 index 00000000000..2c9230e994e --- /dev/null +++ b/public/app/features/trails/RelatedLogs/NoRelatedLogsFoundScene.tsx @@ -0,0 +1,29 @@ +import { SceneObjectBase, type SceneObjectState } from '@grafana/scenes'; +import { Stack, Text, TextLink } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +export class NoRelatedLogsScene extends SceneObjectBase { + static readonly Component = () => { + return ( + + + + Related logs is an experimental feature. + + + + + Related logs are not available for this metric. Try selecting a metric created by a{' '} + + + Loki Recording Rule + + + , or check back later as we expand the various methods for establishing connections between metrics and + logs. + + + + ); + }; +} diff --git a/public/app/features/trails/RelatedLogs/RelatedLogsScene.tsx b/public/app/features/trails/RelatedLogs/RelatedLogsScene.tsx new file mode 100644 index 00000000000..1b771b40ab0 --- /dev/null +++ b/public/app/features/trails/RelatedLogs/RelatedLogsScene.tsx @@ -0,0 +1,166 @@ +import { config } from '@grafana/runtime'; +import { + CustomVariable, + PanelBuilders, + SceneFlexItem, + SceneFlexLayout, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneQueryRunner, + SceneVariableSet, + VariableDependencyConfig, + VariableValueSelectors, + type SceneComponentProps, + type SceneObjectState, + type SceneVariable, +} from '@grafana/scenes'; +import { Stack, LinkButton } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { + fetchAndExtractLokiRecordingRules, + getLokiQueryForRelatedMetric, + getDataSourcesWithRecordingRulesContainingMetric, + type ExtractedRecordingRules, +} from '../Integrations/logsIntegration'; +import { reportExploreMetrics } from '../interactions'; +import { VAR_LOGS_DATASOURCE, VAR_LOGS_DATASOURCE_EXPR, VAR_METRIC_EXPR } from '../shared'; + +import { NoRelatedLogsScene } from './NoRelatedLogsFoundScene'; + +export interface RelatedLogsSceneState extends SceneObjectState { + controls: SceneObject[]; + body: SceneFlexLayout; + lokiRecordingRules: ExtractedRecordingRules; +} + +const LOGS_PANEL_CONTAINER_KEY = 'related_logs/logs_panel_container'; +const RELATED_LOGS_QUERY_KEY = 'related_logs/logs_query'; + +export class RelatedLogsScene extends SceneObjectBase { + constructor(state: Partial) { + super({ + controls: [], + body: new SceneFlexLayout({ + direction: 'column', + height: '400px', + children: [ + new SceneFlexItem({ + key: LOGS_PANEL_CONTAINER_KEY, + body: undefined, + }), + ], + }), + lokiRecordingRules: {}, + ...state, + }); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + private onActivate() { + fetchAndExtractLokiRecordingRules().then((lokiRecordingRules) => { + const selectedMetric = sceneGraph.interpolate(this, VAR_METRIC_EXPR); + const lokiDatasources = getDataSourcesWithRecordingRulesContainingMetric(selectedMetric, lokiRecordingRules); + const logsPanelContainer = sceneGraph.findByKeyAndType(this, LOGS_PANEL_CONTAINER_KEY, SceneFlexItem); + + if (!lokiDatasources?.length) { + logsPanelContainer.setState({ + body: new NoRelatedLogsScene({}), + }); + } else { + logsPanelContainer.setState({ + body: PanelBuilders.logs() + .setTitle('Logs') + .setData( + new SceneQueryRunner({ + datasource: { uid: VAR_LOGS_DATASOURCE_EXPR }, + queries: [], + key: RELATED_LOGS_QUERY_KEY, + }) + ) + .build(), + }); + this.setState({ + $variables: new SceneVariableSet({ + variables: [ + new CustomVariable({ + name: VAR_LOGS_DATASOURCE, + label: 'Logs data source', + query: lokiDatasources?.map((ds) => `${ds.name} : ${ds.uid}`).join(','), + }), + ], + }), + controls: [new VariableValueSelectors({ layout: 'vertical' })], + lokiRecordingRules, + }); + } + }); + } + + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: [VAR_LOGS_DATASOURCE], + onReferencedVariableValueChanged: (variable: SceneVariable) => { + const { name } = variable.state; + + if (name === VAR_LOGS_DATASOURCE) { + const selectedMetric = sceneGraph.interpolate(this, VAR_METRIC_EXPR); + const selectedDatasourceUid = sceneGraph.interpolate(this, VAR_LOGS_DATASOURCE_EXPR); + const lokiQuery = getLokiQueryForRelatedMetric( + selectedMetric, + selectedDatasourceUid, + this.state.lokiRecordingRules + ); + + if (lokiQuery) { + const relatedLogsQuery = sceneGraph.findByKeyAndType(this, RELATED_LOGS_QUERY_KEY, SceneQueryRunner); + relatedLogsQuery.setState({ + queries: [ + { + refId: 'A', + expr: lokiQuery, + maxLines: 100, + }, + ], + }); + } + } + }, + }); + + static readonly Component = ({ model }: SceneComponentProps) => { + const { controls, body } = model.useState(); + + return ( +
+ + + {controls && ( + + {controls.map((control) => ( + + ))} + + )} + reportExploreMetrics('related_logs_action_clicked', { action: 'open_explore_logs' })} + > + Open Explore Logs + + + + +
+ ); + }; +} + +export function buildRelatedLogsScene() { + return new RelatedLogsScene({}); +} diff --git a/public/app/features/trails/interactions.ts b/public/app/features/trails/interactions.ts index 602faaa0ee7..7abe3ba2ef4 100644 --- a/public/app/features/trails/interactions.ts +++ b/public/app/features/trails/interactions.ts @@ -85,6 +85,13 @@ type Interactions = { | 'open_from_embedded' ); }; + // User clicks on one of the action buttons associated with related logs + related_logs_action_clicked: { + action: ( + // Opens Explore Logs + | 'open_explore_logs' + ); + }; // User selects a metric metric_selected: { from: ( diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 16e97986788..a245e3c07ec 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -2,7 +2,7 @@ import { BusEventWithPayload } from '@grafana/data'; import { ConstantVariable, SceneObject } from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; -export type ActionViewType = 'overview' | 'breakdown' | 'label-breakdown' | 'logs' | 'related'; +export type ActionViewType = 'overview' | 'breakdown' | 'label-breakdown' | 'related-logs' | 'related'; export interface ActionViewDefinition { displayName: string; value: ActionViewType; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d308b78e78a..e46df0fb158 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1052,6 +1052,13 @@ "noMatchingValue": "No values found matching; {{filter}}", "sortBy": "Sort by" }, + "related-logs": { + "docsLink": "Loki Recording Rule", + "openExploreLogs": "Open Explore Logs", + "relatedLogsUnavailableAfterDocsLink": ", or check back later as we expand the various methods for establishing connections between metrics and logs.", + "relatedLogsUnavailableBeforeDocsLink": "Related logs are not available for this metric. Try selecting a metric created by a ", + "warnExperimentalFeature": "Related logs is an experimental feature." + }, "viewBy": "View by" }, "export": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b9d8c6e1489..ad2a4bbed2e 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1052,6 +1052,13 @@ "noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}", "sortBy": "Ŝőřŧ þy" }, + "related-logs": { + "docsLink": "Ŀőĸį Ŗęčőřđįʼnģ Ŗūľę", + "openExploreLogs": "Øpęʼn Ēχpľőřę Ŀőģş", + "relatedLogsUnavailableAfterDocsLink": ", őř čĥęčĸ þäčĸ ľäŧęř äş ŵę ęχpäʼnđ ŧĥę väřįőūş męŧĥőđş ƒőř ęşŧäþľįşĥįʼnģ čőʼnʼnęčŧįőʼnş þęŧŵęęʼn męŧřįčş äʼnđ ľőģş.", + "relatedLogsUnavailableBeforeDocsLink": "Ŗęľäŧęđ ľőģş äřę ʼnőŧ äväįľäþľę ƒőř ŧĥįş męŧřįč. Ŧřy şęľęčŧįʼnģ ä męŧřįč čřęäŧęđ þy ä ", + "warnExperimentalFeature": "Ŗęľäŧęđ ľőģş įş äʼn ęχpęřįmęʼnŧäľ ƒęäŧūřę." + }, "viewBy": "Vįęŵ þy" }, "export": {