Explore Metrics: Explore Logs integration (#94656)
* initial logs integration * rename back to Integrations * remove comments * connect related logs in metrics with logsIntegration service * rename * remove comments * feat: related logs with loki expr * fix: layout & var updates * refactor: prefer scene state to context * fix: limit DS select to relevant loki DSes * refactor: use existing utils * refactor: types * fix: tests and types * refactor: simplify * refactor: prefer precise data updates * refactor: prefer variable for key * refactor: simplify panel search * refactor: remove unnecessary short-circuit * fix: ensure single network request for logs * fix: add missing limit to fetched log lines * refactor: add clarity * refactor: organize imports * feat: messaging for No Related Logs case * fix: add missing space * chore: add `exploreMetricsRelatedLogs` feature toggle * feat: link to Explore Logs app * fix: i18n * fix: use sentence case consistent with design system * style: avoid competing with metricscene buttons * fix: capitalization for names * fix: a11y * refactor: clean up and document utils * fix: formatting * chore: run `make i18n-extract` * test: improve coverage * test: fix module resolution side-effects * extract only the first rule refactor: organize imports * remove unnecessary test files --------- Co-authored-by: Nick Richmond <nick.richmond@grafana.com>
This commit is contained in:
co-authored by
Nick Richmond
parent
c7a7f7dce5
commit
82ac9e2bb6
@@ -228,5 +228,6 @@ export interface FeatureToggles {
|
||||
azureMonitorDisableLogLimit?: boolean;
|
||||
dashboardSchemaV2?: boolean;
|
||||
playlistsWatcher?: boolean;
|
||||
exploreMetricsRelatedLogs?: boolean;
|
||||
enableExtensionsAdminPage?: boolean;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<LogsSceneState> {
|
||||
public constructor(state: Partial<LogsSceneState>) {
|
||||
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<LogsScene>) => {
|
||||
const { controls, body } = model.useState();
|
||||
|
||||
return (
|
||||
<Stack gap={1} direction={'column'} grow={1}>
|
||||
{controls && (
|
||||
<Stack gap={1}>
|
||||
{controls.map((control) => (
|
||||
<control.Component key={control.state.key} model={control} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<body.Component model={body} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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({}),
|
||||
});
|
||||
}
|
||||
@@ -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<LabelBreakdownSceneStat
|
||||
}
|
||||
}, [model, useOtelExperience]);
|
||||
|
||||
const relatedLogsScene = new RelatedLogsScene({});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<StatusWrapper {...{ isLoading: loading, blockingMessage }}>
|
||||
@@ -359,6 +364,7 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>{body && <body.Component model={body} />}</div>
|
||||
{relatedLogsFeatureEnabled && <relatedLogsScene.Component model={relatedLogsScene} />}
|
||||
</StatusWrapper>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<DataTrailState> {
|
||||
}
|
||||
/**
|
||||
* 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<DataTrailState> {
|
||||
* 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,
|
||||
|
||||
@@ -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<DataSourceJsonData> = {
|
||||
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<DataSourceJsonData> = {
|
||||
...mockLokiDS1,
|
||||
id: 2,
|
||||
uid: 'loki2',
|
||||
name: 'Loki Secondary',
|
||||
};
|
||||
|
||||
const mockLokiDS3: DataSourceInstanceSettings<DataSourceJsonData> = {
|
||||
...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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
export type FoundLokiDataSource = Pick<DataSourceSettings, 'name' | 'uid'>;
|
||||
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<DataSourceJsonData>) {
|
||||
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<DataSourceJsonData>
|
||||
): 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<string, ExtractedRecordingRule>();
|
||||
// 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<ExtractedRecordingRules>} 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;
|
||||
}
|
||||
@@ -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<MetricActionBarState> {
|
||||
|
||||
@@ -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<SceneObjectState> {
|
||||
static readonly Component = () => {
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text color="warning">
|
||||
<Trans i18nKey="explore-metrics.related-logs.warnExperimentalFeature">
|
||||
Related logs is an experimental feature.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text>
|
||||
<Trans i18nKey="explore-metrics.related-logs.relatedLogsUnavailableBeforeDocsLink">
|
||||
Related logs are not available for this metric. Try selecting a metric created by a{' '}
|
||||
</Trans>
|
||||
<TextLink external href="https://grafana.com/docs/loki/latest/alert/#recording-rules">
|
||||
<Trans i18nKey="explore-metrics.related-logs.docsLink">Loki Recording Rule</Trans>
|
||||
</TextLink>
|
||||
<Trans i18nKey="explore-metrics.related-logs.relatedLogsUnavailableAfterDocsLink">
|
||||
, or check back later as we expand the various methods for establishing connections between metrics and
|
||||
logs.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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<RelatedLogsSceneState> {
|
||||
constructor(state: Partial<RelatedLogsSceneState>) {
|
||||
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<RelatedLogsScene>) => {
|
||||
const { controls, body } = model.useState();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack gap={1} direction={'column'} grow={1}>
|
||||
<Stack gap={1} direction={'row'} grow={1} justifyContent={'space-between'} alignItems={'start'}>
|
||||
{controls && (
|
||||
<Stack gap={1}>
|
||||
{controls.map((control) => (
|
||||
<control.Component key={control.state.key} model={control} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<LinkButton
|
||||
href={`${config.appSubUrl}/a/grafana-lokiexplore-app`} // We prefix with the appSubUrl for environments that don't host grafana at the root.
|
||||
target="_blank"
|
||||
tooltip="Navigate to the Explore Logs app"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => reportExploreMetrics('related_logs_action_clicked', { action: 'open_explore_logs' })}
|
||||
>
|
||||
<Trans i18nKey="explore-metrics.related-logs.openExploreLogs">Open Explore Logs</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
<body.Component model={body} />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRelatedLogsScene() {
|
||||
return new RelatedLogsScene({});
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user