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:
ismail simsek
2024-11-05 17:28:43 +01:00
committed by GitHub
co-authored by Nick Richmond
parent c7a7f7dce5
commit 82ac9e2bb6
18 changed files with 754 additions and 104 deletions
@@ -228,5 +228,6 @@ export interface FeatureToggles {
azureMonitorDisableLogLimit?: boolean;
dashboardSchemaV2?: boolean;
playlistsWatcher?: boolean;
exploreMetricsRelatedLogs?: boolean;
enableExtensionsAdminPage?: boolean;
}
+8
View File
@@ -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",
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
209 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
210 dashboardSchemaV2 experimental @grafana/dashboards-squad false false true
211 playlistsWatcher experimental @grafana/grafana-app-platform-squad false true false
212 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
213 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
+4
View File
@@ -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"
+14
View File
@@ -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>
);
+10 -10
View File
@@ -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: (
+1 -1
View File
@@ -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;
+7
View File
@@ -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": {