Files
Piotr Jamróz bbf01a6383 Flame Graph: Analyze with Grafana Assistant (#108684)
* Bare-bones mocked integration

* Create correct context based on the query

* Add data source name

* Do not bundle grafana/assistant with flame graph

* Rename component

* Add tests

* Mock grafana/assistant

* Update feature toggle and allow hiding the button

* Update deps

* Update types

* Update yarn.lock

* Fix typo in feature toggle description

* Enable grafanaAssistantInProfilesDrilldown by default

* Enable grafanaAssistantInProfilesDrilldown by default

* Show Analyze Flame Graph button only if there's context for the assistant
2025-08-19 09:54:00 +02:00

343 lines
11 KiB
TypeScript

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<typeof createContext>;
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<Partial<GrafanaPyroscopeDataQuery>> = []
): DataQueryRequest<GrafanaPyroscopeDataQuery> => ({
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',
},
});
});
});
});