diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index c5b1d0d05d1..c7ec80a9662 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -76,6 +76,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes | | `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes | | `unifiedNavbars` | Enables unified navbars | | +| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | ## Public preview feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a8d6dd05efc..cc2d2b8b63b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -950,6 +950,11 @@ export interface FeatureToggles { */ metricsFromProfiles?: boolean; /** + * Enables integration with Grafana Assistant in Profiles Drilldown + * @default true + */ + grafanaAssistantInProfilesDrilldown?: boolean; + /** * Enables using PGX instead of libpq for PostgreSQL datasource */ postgresDSUsePGX?: boolean; diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 06914eb8915..fb5b0fa7939 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -83,6 +83,7 @@ "typescript": "5.9.2" }, "peerDependencies": { + "@grafana/assistant": "^0.0.12", "react": "^18.0.0", "react-dom": "^18.0.0" } diff --git a/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx b/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx new file mode 100644 index 00000000000..005e1d9b21a --- /dev/null +++ b/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx @@ -0,0 +1,34 @@ +import { ChatContextItem, useAssistant } from '@grafana/assistant'; +import { Button } from '@grafana/ui'; + +type Props = { + assistantContext: ChatContextItem[]; + className?: string; +}; + +export function AnalyzeFlameGraphButton(props: Props) { + const { assistantContext, className } = props; + const [isAvailable, openAssistant] = useAssistant(); + + if (!isAvailable || !openAssistant || assistantContext.length === 0) { + return null; + } + + return ( + + openAssistant({ + prompt: 'Analyze Flame Graph', + context: assistantContext, + }) + } + variant="secondary" + fill="outline" + icon="ai-sparkle" + size="sm" + > + Analyze Flame Graph + + ); +} diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx index 5be3bb3c3db..85ce8dd3470 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx @@ -9,6 +9,11 @@ import { data } from './FlameGraph/testData/dataNestedSet'; import FlameGraphContainer, { labelSearch } from './FlameGraphContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(() => [false, null]), // [isAvailable, openAssistant] + createContext: jest.fn(), +})); + jest.mock('react-use', () => ({ ...jest.requireActual('react-use'), useMeasure: () => { diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 706702142db..da09ea26739 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -14,6 +14,7 @@ import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; +import { getAssistantContextFromDataFrame } from './utils'; const ufuzzy = new uFuzzy(); @@ -77,6 +78,14 @@ export type Props = { * Whether or not to keep any focused item when the profile data changes. */ keepFocusOnDataChange?: boolean; + + /** + * If true, the assistant button will be shown in the header if available. + * This is needed mainly for Profiles Drilldown where in some cases we need to hide the button to show alternative + * option to use AI. + * @default true + */ + showAnalyzeWithAssistant?: boolean; }; const FlameGraphContainer = ({ @@ -93,6 +102,7 @@ const FlameGraphContainer = ({ disableCollapsing, keepFocusOnDataChange, getExtraContextMenuButtons, + showAnalyzeWithAssistant = true, }: Props) => { const [focusedItemData, setFocusedItemData] = useState(); @@ -293,6 +303,7 @@ const FlameGraphContainer = ({ isDiffMode={dataContainer.isDiffFlamegraph()} setCollapsedMap={setCollapsedMap} collapsedMap={collapsedMap} + assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined} /> )} diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx index 546450dad2b..0ca621d9f64 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx @@ -7,6 +7,11 @@ import { CollapsedMap } from './FlameGraph/dataTransform'; import FlameGraphHeader from './FlameGraphHeader'; import { ColorScheme, SelectedView } from './types'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(() => [false, null]), // [isAvailable, openAssistant] + createContext: jest.fn(), +})); + describe('FlameGraphHeader', () => { function setup(props: Partial> = {}) { const setSearch = jest.fn(); diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx index 91541f216b7..e51dd78b051 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx @@ -3,9 +3,11 @@ import { useEffect, useState } from 'react'; import * as React from 'react'; import { useDebounce, usePrevious } from 'react-use'; +import { ChatContextItem } from '@grafana/assistant'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { AnalyzeFlameGraphButton } from './AnalyzeFlameGraphButton'; import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors'; import { CollapsedMap } from './FlameGraph/dataTransform'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; @@ -30,6 +32,8 @@ type Props = { collapsedMap: CollapsedMap; extraHeaderElements?: React.ReactNode; + + assistantContext?: ChatContextItem[]; }; const FlameGraphHeader = ({ @@ -50,6 +54,7 @@ const FlameGraphHeader = ({ isDiffMode, setCollapsedMap, collapsedMap, + assistantContext, }: Props) => { const styles = useStyles2(getStyles); const [localSearch, setLocalSearch] = useSearchInput(search, setSearch); @@ -84,6 +89,9 @@ const FlameGraphHeader = ({ + {assistantContext && ( + + )} {showResetButton && ( { constructor( @@ -45,10 +51,12 @@ export class PyroscopeDataSource extends DataSourceWithBackend { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index b97b9af0c3e..1276e101247 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -39,6 +39,7 @@ "webpack": "5.101.0" }, "peerDependencies": { + "@grafana/assistant": "^0.0.12", "@grafana/runtime": "*" }, "scripts": { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.test.ts new file mode 100644 index 00000000000..e8c17b0fac1 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.test.ts @@ -0,0 +1,342 @@ +import { createContext, ItemDataType } from '@grafana/assistant'; +import { + DataFrame, + DataQueryResponse, + DataQueryRequest, + FieldType, + MutableDataFrame, + dateTime, + PreferredVisualisationType, +} from '@grafana/data'; + +import { GrafanaPyroscopeDataQuery } from './dataquery.gen'; +import { enrichDataFrameWithAssistantContentMapper } from './utils'; + +// Mock the createContext function +jest.mock('@grafana/assistant', () => ({ + createContext: jest.fn(), + ItemDataType: { + Datasource: 'datasource', + Structured: 'structured', + }, +})); + +const mockCreateContext = createContext as jest.MockedFunction; + +describe('enrichDataFrameWithAssistantContentMapper', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateContext.mockImplementation((type, data) => ({ + type, + data, + node: { id: 'test-id', type: 'test', name: 'test-node', navigable: true }, + occurrences: [], + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createMockRequest = ( + targets: Array> = [] + ): DataQueryRequest => ({ + requestId: 'test-request', + interval: '1s', + intervalMs: 1000, + maxDataPoints: 1000, + range: { + from: dateTime('2023-01-01T00:00:00Z'), + to: dateTime('2023-01-01T01:00:00Z'), + raw: { + from: 'now-1h', + to: 'now', + }, + }, + scopedVars: {}, + timezone: 'UTC', + app: 'explore', + startTime: Date.now(), + targets: targets.map((target, index) => ({ + refId: `A${index}`, + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + groupBy: [], + queryType: 'profile' as const, + datasource: { + uid: 'test-uid', + type: 'grafana-pyroscope-datasource', + }, + ...target, + })) as GrafanaPyroscopeDataQuery[], + }); + + const createMockDataFrame = (refId: string, preferredVisualisationType?: PreferredVisualisationType): DataFrame => { + const frame = new MutableDataFrame({ + refId, + fields: [ + { name: 'time', type: FieldType.time, values: [1672531200000] }, + { name: 'value', type: FieldType.number, values: [100] }, + ], + }); + + if (preferredVisualisationType) { + frame.meta = { + preferredVisualisationType, + }; + } + + return frame; + }; + + const createMockResponse = (dataFrames: DataFrame[]): DataQueryResponse => ({ + data: dataFrames, + }); + + describe('when processing flamegraph data frames', () => { + it('should enrich flamegraph data frame with assistant context', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + }, + ]); + const dataFrame = createMockDataFrame('A0', 'flamegraph'); + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'PyroscopeDatasource'); + const result = mapper(response); + + expect(result.data).toHaveLength(1); + expect(result.data[0].meta?.custom?.assistantContext).toBeDefined(); + expect(mockCreateContext).toHaveBeenCalledTimes(2); + + // Verify datasource context + expect(mockCreateContext).toHaveBeenCalledWith(ItemDataType.Datasource, { + datasourceName: 'PyroscopeDatasource', + datasourceUid: 'test-uid', + datasourceType: 'grafana-pyroscope-datasource', + }); + + // Verify structured context + expect(mockCreateContext).toHaveBeenCalledWith(ItemDataType.Structured, { + title: 'Analyze Flame Graph', + data: { + start: request.range.from.valueOf(), + end: request.range.to.valueOf(), + profile_type_id: 'cpu', + label_selector: '{service="test"}', + operation: 'execute', + }, + }); + }); + + it('should preserve existing meta.custom properties', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'memory', + labelSelector: '{app="backend"}', + }, + ]); + const dataFrame = createMockDataFrame('A0', 'flamegraph'); + dataFrame.meta = { + preferredVisualisationType: 'flamegraph', + custom: { + existingProperty: 'value', + }, + }; + + const response = createMockResponse([dataFrame]); + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data[0].meta?.custom?.existingProperty).toBe('value'); + expect(result.data[0].meta?.custom?.assistantContext).toBeDefined(); + }); + + it('should handle multiple flamegraph data frames', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test1"}', + }, + { + refId: 'A1', + profileTypeId: 'memory', + labelSelector: '{service="test2"}', + }, + ]); + + const dataFrames = [createMockDataFrame('A0', 'flamegraph'), createMockDataFrame('A1', 'flamegraph')]; + const response = createMockResponse(dataFrames); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data).toHaveLength(2); + expect(result.data[0].meta?.custom?.assistantContext).toBeDefined(); + expect(result.data[1].meta?.custom?.assistantContext).toBeDefined(); + expect(mockCreateContext).toHaveBeenCalledTimes(4); // 2 contexts per frame + }); + }); + + describe('when processing non-flamegraph data frames', () => { + it('should not modify data frames without flamegraph visualization type', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + }, + ]); + const dataFrame = createMockDataFrame('A0', 'table'); + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data[0].meta?.custom?.assistantContext).toBeUndefined(); + expect(mockCreateContext).not.toHaveBeenCalled(); + }); + }); + + describe('when handling edge cases', () => { + it('should not add context data frame without matching query target', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + }, + ]); + const dataFrame = createMockDataFrame('B0', 'flamegraph'); // Different refId + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data[0]).toBe(dataFrame); // Should remain unchanged + expect(mockCreateContext).not.toHaveBeenCalled(); + }); + + it('should not add context when query has no datasource information', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + datasource: undefined, + }, + ]); + const dataFrame = createMockDataFrame('A0', 'flamegraph'); + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data[0]).toBe(dataFrame); // Should remain unchanged + expect(mockCreateContext).not.toHaveBeenCalled(); + }); + + it('should not add context if query has incomplete datasource information', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + datasource: { + uid: 'test-uid', + // Missing type + }, + }, + ]); + const dataFrame = createMockDataFrame('A0', 'flamegraph'); + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data[0]).toBe(dataFrame); // Should remain unchanged + expect(mockCreateContext).not.toHaveBeenCalled(); + }); + + it('should not add context with empty response data', () => { + const request = createMockRequest([]); + const response = createMockResponse([]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data).toHaveLength(0); + expect(mockCreateContext).not.toHaveBeenCalled(); + }); + + it('should handle mixed data frame types', () => { + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + }, + { + refId: 'A1', + profileTypeId: 'memory', + labelSelector: '{service="test2"}', + }, + ]); + + const dataFrames = [ + createMockDataFrame('A0', 'flamegraph'), + createMockDataFrame('A1', 'table'), + createMockDataFrame('A0', 'flamegraph'), // Another flamegraph with same refId + ]; + const response = createMockResponse(dataFrames); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + const result = mapper(response); + + expect(result.data).toHaveLength(3); + expect(result.data[0].meta?.custom?.assistantContext).toBeDefined(); // Flamegraph + expect(result.data[1].meta?.custom?.assistantContext).toBeUndefined(); // Table + expect(result.data[2].meta?.custom?.assistantContext).toBeDefined(); // Flamegraph + expect(mockCreateContext).toHaveBeenCalledTimes(4); // 2 contexts for each flamegraph + }); + }); + + describe('context creation', () => { + it('should create context with correct time range values', () => { + const fromTime = dateTime('2023-06-15T10:00:00Z'); + const toTime = dateTime('2023-06-15T11:00:00Z'); + + const request = createMockRequest([ + { + refId: 'A0', + profileTypeId: 'cpu', + labelSelector: '{service="test"}', + }, + ]); + request.range.from = fromTime; + request.range.to = toTime; + + const dataFrame = createMockDataFrame('A0', 'flamegraph'); + const response = createMockResponse([dataFrame]); + + const mapper = enrichDataFrameWithAssistantContentMapper(request, 'TestDatasource'); + mapper(response); + + expect(mockCreateContext).toHaveBeenCalledWith(ItemDataType.Structured, { + title: 'Analyze Flame Graph', + data: { + start: fromTime.valueOf(), + end: toTime.valueOf(), + profile_type_id: 'cpu', + label_selector: '{service="test"}', + operation: 'execute', + }, + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts index dc63683a40b..3440d29d584 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/utils.ts @@ -1,7 +1,16 @@ import { invert } from 'lodash'; import Prism, { Grammar, Token } from 'prismjs'; -import { AbstractLabelMatcher, AbstractLabelOperator } from '@grafana/data'; +import { createContext, ItemDataType } from '@grafana/assistant'; +import { + AbstractLabelMatcher, + AbstractLabelOperator, + DataFrame, + DataQueryResponse, + DataQueryRequest, +} from '@grafana/data'; + +import { GrafanaPyroscopeDataQuery } from './dataquery.gen'; export function extractLabelMatchers(tokens: Array): AbstractLabelMatcher[] { const labelMatchers: AbstractLabelMatcher[] = []; @@ -131,3 +140,48 @@ export const grammar: Grammar = { }, punctuation: /[{}(),.]/, }; + +export function enrichDataFrameWithAssistantContentMapper( + request: DataQueryRequest, + datasourceName: string +) { + const validTargets = request.targets; + return (response: DataQueryResponse) => { + response.data = response.data.map((data: DataFrame) => { + if (data.meta?.preferredVisualisationType !== 'flamegraph') { + return data; + } + + const query = validTargets.find((target) => target.refId === data.refId); + if (!query || !query.datasource?.uid || !query.datasource?.type) { + return data; + } + + const context = [ + createContext(ItemDataType.Datasource, { + datasourceName: datasourceName, + datasourceUid: query.datasource.uid, + datasourceType: query.datasource.type, + }), + createContext(ItemDataType.Structured, { + title: 'Analyze Flame Graph', + data: { + start: request.range.from.valueOf(), + end: request.range.to.valueOf(), + profile_type_id: query.profileTypeId, + label_selector: query.labelSelector, + operation: 'execute', + }, + }), + ]; + + data.meta = data.meta || {}; + data.meta.custom = { + ...data.meta.custom, + assistantContext: context, + }; + return data; + }); + return response; + }; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts index f48f8b1a693..5c3d1f2339d 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/webpack.config.ts @@ -1,3 +1,5 @@ +import { merge } from 'webpack-merge'; + import config, { type Env } from '@grafana/plugin-configs/webpack.config.ts'; const configWithFallback = async (env: Env) => { @@ -9,7 +11,9 @@ const configWithFallback = async (env: Env) => { string_decoder: false, }; } - return response; + return merge(response, { + externals: ['grafana/assistant'], + }); }; export default configWithFallback; diff --git a/yarn.lock b/yarn.lock index bb7ae5d1078..a27155c3ac9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2648,6 +2648,7 @@ __metadata: typescript: "npm:5.9.2" webpack: "npm:5.101.0" peerDependencies: + "@grafana/assistant": ^0.0.12 "@grafana/runtime": "*" languageName: unknown linkType: soft @@ -3285,6 +3286,7 @@ __metadata: tslib: "npm:2.8.1" typescript: "npm:5.9.2" peerDependencies: + "@grafana/assistant": ^0.0.12 react: ^18.0.0 react-dom: ^18.0.0 languageName: unknown