Trace View: Add link from the Trace View to the Profiles Drilldown (#101422)
* Add link from the Trace View to the Profiles App via Profiles for this span button dropdown * Fix tests * Lint * Fix test * Lint * Fallback if plugin extension does not exist * Move span detail link buttons to their own file * Add tests * Update type name * Update naming * Prettier * Move button * Remove fallback * Use proper time range * Betterer * Update test * Use CoreApp * Betterer * Simplify context * Update tests --------- Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
@@ -185,6 +185,7 @@ export enum PluginExtensionPoints {
|
||||
DataSourceConfig = 'grafana/datasources/config',
|
||||
ExploreToolbarAction = 'grafana/explore/toolbar/action',
|
||||
UserProfileTab = 'grafana/user/profile/tab',
|
||||
TraceViewDetails = 'grafana/traceview/details',
|
||||
}
|
||||
|
||||
export type PluginExtensionPanelContext = {
|
||||
|
||||
@@ -514,6 +514,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
dataFrames={dataFrames}
|
||||
splitOpenFn={this.onSplitOpen('traceView')}
|
||||
scrollElement={this.scrollElement}
|
||||
timeRange={queryResponse.timeRange}
|
||||
/>
|
||||
</ContentOutlineItem>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event';
|
||||
import { createRef } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { DataFrame, MutableDataFrame } from '@grafana/data';
|
||||
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataFrame, MutableDataFrame, TimeRange } from '@grafana/data';
|
||||
import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
|
||||
|
||||
import { configureStore } from '../../../store/configureStore';
|
||||
|
||||
@@ -25,6 +25,7 @@ function getTraceView(frames: DataFrame[]) {
|
||||
traceProp={transformDataFrames(frames[0])!}
|
||||
datasource={undefined}
|
||||
topOfViewRef={topOfViewRef}
|
||||
timeRange={{} as TimeRange}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
@@ -47,6 +48,11 @@ function renderTraceViewNew() {
|
||||
|
||||
describe('TraceView', () => {
|
||||
beforeAll(() => {
|
||||
setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
links: [],
|
||||
}));
|
||||
|
||||
setDataSourceSrv({
|
||||
getInstanceSettings() {
|
||||
return undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LinkModel,
|
||||
mapInternalLinkToExplore,
|
||||
SplitOpen,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { getTraceToLogsOptions, TraceToMetricsData, TraceToProfilesData } from '@grafana/o11y-ds-frontend';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
@@ -67,6 +68,7 @@ type Props = {
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
|
||||
spanFilters?: SearchProps;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export function TraceView(props: Props) {
|
||||
@@ -238,6 +240,8 @@ export function TraceView(props: Props) {
|
||||
setTraceFlameGraphs={setTraceFlameGraphs}
|
||||
redrawListView={redrawListView}
|
||||
setRedrawListView={setRedrawListView}
|
||||
timeRange={props.timeRange}
|
||||
app={exploreId ? CoreApp.Explore : CoreApp.Unknown}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,8 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
import { configureStore } from '../../../store/configureStore';
|
||||
|
||||
import { frameOld } from './TraceView.test';
|
||||
@@ -11,6 +13,7 @@ jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
usePluginLinks: jest.fn().mockReturnValue({ isLoading: false, links: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -19,7 +22,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
|
||||
|
||||
const { container, baseElement } = render(
|
||||
<Provider store={store}>
|
||||
<TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} />
|
||||
<TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} timeRange={{} as TimeRange} />
|
||||
</Provider>
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DataFrame, SplitOpen } from '@grafana/data';
|
||||
import { DataFrame, SplitOpen, TimeRange } from '@grafana/data';
|
||||
import { PanelChrome } from '@grafana/ui';
|
||||
import { StoreState, useSelector } from 'app/types';
|
||||
|
||||
@@ -12,12 +12,13 @@ interface Props {
|
||||
splitOpenFn: SplitOpen;
|
||||
exploreId: string;
|
||||
scrollElement?: Element;
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export function TraceViewContainer(props: Props) {
|
||||
// At this point we only show single trace
|
||||
const frame = props.dataFrames[0];
|
||||
const { dataFrames, splitOpenFn, exploreId, scrollElement } = props;
|
||||
const { dataFrames, splitOpenFn, exploreId, scrollElement, timeRange } = props;
|
||||
const traceProp = useMemo(() => transformDataFrames(frame), [frame]);
|
||||
const datasource = useSelector(
|
||||
(state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined
|
||||
@@ -36,6 +37,7 @@ export function TraceViewContainer(props: Props) {
|
||||
scrollElement={scrollElement}
|
||||
traceProp={traceProp}
|
||||
datasource={datasource}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</PanelChrome>
|
||||
);
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { CoreApp, TimeRange } from '@grafana/data';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
|
||||
import { SpanLinkType } from '../../types/links';
|
||||
import { TraceSpan } from '../../types/trace';
|
||||
|
||||
import { getSpanDetailLinkButtons, getProfileLinkButtonsContext } from './SpanDetailLinkButtons';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
usePluginLinks: jest.fn().mockReturnValue({ isLoading: false, links: [] }),
|
||||
}));
|
||||
|
||||
const span = {
|
||||
process: {
|
||||
serviceName: 'test-service',
|
||||
},
|
||||
tags: [{ key: 'pyroscope.profile.id', value: 'test-profile' }],
|
||||
} as TraceSpan;
|
||||
|
||||
const createSpanLink = jest.fn();
|
||||
const timeRange = {
|
||||
from: new Date(0),
|
||||
to: new Date(1000),
|
||||
} as unknown as TimeRange;
|
||||
|
||||
describe('getSpanDetailLinkButtons', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty buttons when createSpanLink is not provided', () => {
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink: undefined,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: undefined,
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should create log link button when logs link exists', () => {
|
||||
createSpanLink.mockReturnValue([{ type: SpanLinkType.Logs, href: '/logs', title: 'Logs' }]);
|
||||
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: undefined,
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeDefined();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should create profile link button when profiles link exists', () => {
|
||||
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
|
||||
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: {
|
||||
datasourceUid: 'test-uid',
|
||||
profileTypeId: 'test-type',
|
||||
customQuery: false,
|
||||
},
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeDefined();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should create session link button when session link exists', () => {
|
||||
createSpanLink.mockReturnValue([{ type: SpanLinkType.Session, href: '/session', title: 'Session' }]);
|
||||
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: undefined,
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create profile drilldown button when plugin link exists', () => {
|
||||
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
|
||||
(usePluginLinks as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
links: [
|
||||
{
|
||||
pluginId: 'grafana-pyroscope-app',
|
||||
title: 'Open in Profiles Drilldown',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: {
|
||||
datasourceUid: 'test-uid',
|
||||
profileTypeId: 'test-type',
|
||||
customQuery: false,
|
||||
},
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.profileLinkButtons).toBeDefined();
|
||||
// Should render both the original profile link and the drilldown button
|
||||
expect(result.profileLinkButtons?.props.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not create profile drilldown button when not in Explore', () => {
|
||||
createSpanLink.mockReturnValue([{ type: SpanLinkType.Profiles, href: '/profiles', title: RelatedProfilesTitle }]);
|
||||
(usePluginLinks as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
links: [
|
||||
{
|
||||
pluginId: 'grafana-pyroscope-app',
|
||||
title: 'Open in Profiles Drilldown',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType: 'test',
|
||||
traceToProfilesOptions: {
|
||||
datasourceUid: 'test-uid',
|
||||
profileTypeId: 'test-type',
|
||||
customQuery: false,
|
||||
},
|
||||
timeRange,
|
||||
app: CoreApp.Dashboard,
|
||||
});
|
||||
|
||||
expect(result.profileLinkButtons).toBeDefined();
|
||||
// Should only render the original profile link
|
||||
expect(result.profileLinkButtons?.props.children).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfileLinkButtonsContext', () => {
|
||||
const traceToProfilesOptions = {
|
||||
datasourceUid: 'test-uid',
|
||||
profileTypeId: 'test-type',
|
||||
customQuery: false,
|
||||
};
|
||||
|
||||
it('should create context with all properties', () => {
|
||||
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
|
||||
|
||||
expect(context).toEqual({
|
||||
serviceName: 'test-service',
|
||||
profileTypeId: 'test-type',
|
||||
spanSelector: 'test-profile',
|
||||
explorationType: 'flame-graph',
|
||||
timeRange: {
|
||||
from: new Date(0).toISOString(),
|
||||
to: new Date(1000).toISOString(),
|
||||
},
|
||||
datasource: {
|
||||
uid: 'test-uid',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing traceToProfilesOptions', () => {
|
||||
const context = getProfileLinkButtonsContext(span, undefined, timeRange);
|
||||
|
||||
expect(context).toEqual({
|
||||
serviceName: 'test-service',
|
||||
profileTypeId: '',
|
||||
spanSelector: 'test-profile',
|
||||
explorationType: 'flame-graph',
|
||||
timeRange: {
|
||||
from: new Date(0).toISOString(),
|
||||
to: new Date(1000).toISOString(),
|
||||
},
|
||||
datasource: {
|
||||
uid: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing service name', () => {
|
||||
const spanWithoutService = {
|
||||
process: {},
|
||||
tags: [{ key: 'pyroscope.profile.id', value: 'test-profile' }],
|
||||
} as TraceSpan;
|
||||
|
||||
const context = getProfileLinkButtonsContext(spanWithoutService, traceToProfilesOptions, timeRange);
|
||||
|
||||
expect(context.serviceName).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing profile ID tag', () => {
|
||||
const spanWithoutProfileId = {
|
||||
process: { serviceName: 'test-service' },
|
||||
tags: [],
|
||||
} as unknown as TraceSpan;
|
||||
|
||||
const context = getProfileLinkButtonsContext(spanWithoutProfileId, traceToProfilesOptions, timeRange);
|
||||
|
||||
expect(context.spanSelector).toBe('');
|
||||
});
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, IconName, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data';
|
||||
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
||||
import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
import { Button, DataLinkButton } from '@grafana/ui';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
|
||||
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
|
||||
import { SpanLinkFunc } from '../../types';
|
||||
import { SpanLinkDef, SpanLinkType } from '../../types/links';
|
||||
import { TraceSpan } from '../../types/trace';
|
||||
|
||||
export type ProfilesButtonContext = {
|
||||
serviceName: string;
|
||||
profileTypeId: string;
|
||||
spanSelector: string;
|
||||
explorationType: string;
|
||||
timeRange: RawTimeRange;
|
||||
datasource: DataSourceRef;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
span: TraceSpan;
|
||||
traceToProfilesOptions?: TraceToProfilesOptions;
|
||||
datasourceType: string;
|
||||
timeRange: TimeRange;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
export const getSpanDetailLinkButtons = (props: Props) => {
|
||||
const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app } = props;
|
||||
|
||||
let logLinkButton: JSX.Element | null = null;
|
||||
let profileLinkButton: JSX.Element | null = null;
|
||||
let sessionLinkButton: JSX.Element | null = null;
|
||||
if (createSpanLink) {
|
||||
const links = createSpanLink(span);
|
||||
const logsLink = links?.filter((link) => link.type === SpanLinkType.Logs);
|
||||
if (links && logsLink && logsLink.length > 0) {
|
||||
logLinkButton = createLinkButton(logsLink[0], SpanLinkType.Logs, 'Logs for this span', 'gf-logs', datasourceType);
|
||||
}
|
||||
const profilesLink = links?.filter(
|
||||
(link) => link.type === SpanLinkType.Profiles && link.title === RelatedProfilesTitle
|
||||
);
|
||||
if (links && profilesLink && profilesLink.length > 0) {
|
||||
profileLinkButton = createLinkButton(
|
||||
profilesLink[0],
|
||||
SpanLinkType.Profiles,
|
||||
'Profiles for this span',
|
||||
'link',
|
||||
datasourceType
|
||||
);
|
||||
}
|
||||
const sessionLink = links?.filter((link) => link.type === SpanLinkType.Session);
|
||||
if (links && sessionLink && sessionLink.length > 0) {
|
||||
sessionLinkButton = createLinkButton(
|
||||
sessionLink[0],
|
||||
SpanLinkType.Session,
|
||||
'Session for this span',
|
||||
'frontend-observability',
|
||||
datasourceType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let profileLinkButtons = profileLinkButton;
|
||||
if (profileLinkButton) {
|
||||
// ensure we have a profile link
|
||||
const profilesDrilldownPluginId = 'grafana-pyroscope-app';
|
||||
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
|
||||
|
||||
// if in explore, use the plugin extension point to get the link
|
||||
// note: plugin extension point links are not currently supported in panel plugins
|
||||
if (app === CoreApp.Explore) {
|
||||
const extensionPointId = PluginExtensionPoints.TraceViewDetails;
|
||||
const { links } = usePluginLinks({ extensionPointId, context, limitPerPlugin: 1 });
|
||||
const link = links && links.length > 0 ? links.find((link) => link.pluginId === profilesDrilldownPluginId) : null;
|
||||
const label = 'Open in Profiles Drilldown';
|
||||
|
||||
// if we have a plugin link, add a button to open in Grafana Profiles Drilldown
|
||||
if (link) {
|
||||
const profileDrilldownLinkButton = (
|
||||
<Button
|
||||
icon="link"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (link && link.onClick) {
|
||||
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
|
||||
datasourceType,
|
||||
grafana_version: config.buildInfo.version,
|
||||
type: SpanLinkType.ProfilesDrilldown,
|
||||
location: 'spanDetails',
|
||||
});
|
||||
|
||||
link.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
profileLinkButtons = (
|
||||
<>
|
||||
{profileLinkButton}
|
||||
{profileDrilldownLinkButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { profileLinkButtons, logLinkButton, sessionLinkButton };
|
||||
};
|
||||
|
||||
export const getProfileLinkButtonsContext = (
|
||||
span: TraceSpan,
|
||||
traceToProfilesOptions: TraceToProfilesOptions | undefined,
|
||||
timeRange: TimeRange
|
||||
) => {
|
||||
const spanSelector = span.tags.filter((tag) => tag.key === pyroscopeProfileIdTagKey);
|
||||
const context: ProfilesButtonContext = {
|
||||
serviceName: span.process.serviceName ?? '',
|
||||
profileTypeId: traceToProfilesOptions?.profileTypeId ?? '',
|
||||
spanSelector: spanSelector.length === 1 && spanSelector[0].value ? spanSelector[0].value : '',
|
||||
explorationType: 'flame-graph',
|
||||
timeRange: {
|
||||
from: timeRange.from.toISOString(),
|
||||
to: timeRange.to.toISOString(),
|
||||
},
|
||||
datasource: { uid: traceToProfilesOptions?.datasourceUid },
|
||||
};
|
||||
return context;
|
||||
};
|
||||
|
||||
const createLinkButton = (
|
||||
link: SpanLinkDef,
|
||||
type: SpanLinkType,
|
||||
title: string,
|
||||
icon: IconName,
|
||||
datasourceType: string,
|
||||
className?: string
|
||||
) => {
|
||||
return (
|
||||
<DataLinkButton
|
||||
link={{
|
||||
...link,
|
||||
title: title,
|
||||
target: '_blank',
|
||||
origin: link.field,
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
// DataLinkButton assumes if you provide an onClick event you would want to prevent default behavior like navigation
|
||||
// In this case, if an onClick is not defined, restore navigation to the provided href while keeping the tracking
|
||||
// this interaction will not be tracked with link right clicks
|
||||
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
|
||||
datasourceType,
|
||||
grafana_version: config.buildInfo.version,
|
||||
type,
|
||||
location: 'spanDetails',
|
||||
});
|
||||
|
||||
if (link.onClick) {
|
||||
link.onClick?.(event);
|
||||
} else {
|
||||
locationService.push(link.href);
|
||||
}
|
||||
},
|
||||
}}
|
||||
buttonProps={{ icon, className }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+12
-1
@@ -19,7 +19,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createDataFrame, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { data } from '@grafana/flamegraph';
|
||||
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
|
||||
|
||||
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
|
||||
import traceGenerator from '../../demo/trace-generators';
|
||||
@@ -70,6 +70,12 @@ describe('<SpanDetail>', () => {
|
||||
createFocusSpanLink: jest.fn().mockReturnValue({}),
|
||||
traceFlameGraphs: { [span.spanID]: createDataFrame(data) },
|
||||
setRedrawListView: jest.fn(),
|
||||
timeRange: {
|
||||
raw: {
|
||||
from: 0,
|
||||
to: 1000000000000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
span.tags = [
|
||||
@@ -156,6 +162,11 @@ describe('<SpanDetail>', () => {
|
||||
props.logsToggle.mockReset();
|
||||
props.logItemToggle.mockReset();
|
||||
|
||||
setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
links: [],
|
||||
}));
|
||||
|
||||
setDataSourceSrv({
|
||||
getList() {
|
||||
return [pyroSettings];
|
||||
|
||||
+17
-63
@@ -15,29 +15,26 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { SpanStatusCode } from '@opentelemetry/api';
|
||||
import cx from 'classnames';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
dateTimeFormat,
|
||||
GrafanaTheme2,
|
||||
IconName,
|
||||
LinkModel,
|
||||
TimeRange,
|
||||
TraceKeyValuePair,
|
||||
TraceLog,
|
||||
} from '@grafana/data';
|
||||
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import { DataLinkButton, Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
import { Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
|
||||
import { autoColor } from '../../Theme';
|
||||
import LabeledList from '../../common/LabeledList';
|
||||
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE } from '../../constants/span';
|
||||
import { SpanLinkFunc, TNil } from '../../types';
|
||||
import { SpanLinkDef, SpanLinkType } from '../../types/links';
|
||||
import { TraceLink, TraceSpan, TraceSpanReference } from '../../types/trace';
|
||||
import { formatDuration } from '../utils';
|
||||
|
||||
@@ -46,6 +43,7 @@ import AccordianLogs from './AccordianLogs';
|
||||
import AccordianReferences from './AccordianReferences';
|
||||
import AccordianText from './AccordianText';
|
||||
import DetailState from './DetailState';
|
||||
import { getSpanDetailLinkButtons } from './SpanDetailLinkButtons';
|
||||
import SpanFlameGraph from './SpanFlameGraph';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
@@ -168,6 +166,8 @@ export type SpanDetailProps = {
|
||||
traceFlameGraphs: TraceFlameGraphs;
|
||||
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
|
||||
setRedrawListView: (redraw: {}) => void;
|
||||
timeRange: TimeRange;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
export default function SpanDetail(props: SpanDetailProps) {
|
||||
@@ -193,6 +193,8 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
setTraceFlameGraphs,
|
||||
traceToProfilesOptions,
|
||||
setRedrawListView,
|
||||
timeRange,
|
||||
app,
|
||||
} = props;
|
||||
const {
|
||||
isTagsOpen,
|
||||
@@ -289,62 +291,14 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const createLinkButton = (link: SpanLinkDef, type: SpanLinkType, title: string, icon: IconName) => {
|
||||
return (
|
||||
<DataLinkButton
|
||||
link={{
|
||||
...link,
|
||||
title: title,
|
||||
target: '_blank',
|
||||
origin: link.field,
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
// DataLinkButton assumes if you provide an onClick event you would want to prevent default behavior like navigation
|
||||
// In this case, if an onClick is not defined, restore navigation to the provided href while keeping the tracking
|
||||
// this interaction will not be tracked with link right clicks
|
||||
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
|
||||
datasourceType: datasourceType,
|
||||
grafana_version: config.buildInfo.version,
|
||||
type,
|
||||
location: 'spanDetails',
|
||||
});
|
||||
|
||||
if (link.onClick) {
|
||||
link.onClick?.(event);
|
||||
} else {
|
||||
locationService.push(link.href);
|
||||
}
|
||||
},
|
||||
}}
|
||||
buttonProps={{ icon }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
let logLinkButton: JSX.Element | null = null;
|
||||
let profileLinkButton: JSX.Element | null = null;
|
||||
let sessionLinkButton: JSX.Element | null = null;
|
||||
if (createSpanLink) {
|
||||
const links = createSpanLink(span);
|
||||
const logsLink = links?.filter((link) => link.type === SpanLinkType.Logs);
|
||||
if (links && logsLink && logsLink.length > 0) {
|
||||
logLinkButton = createLinkButton(logsLink[0], SpanLinkType.Logs, 'Logs for this span', 'gf-logs');
|
||||
}
|
||||
const profilesLink = links?.filter(
|
||||
(link) => link.type === SpanLinkType.Profiles && link.title === RelatedProfilesTitle
|
||||
);
|
||||
if (links && profilesLink && profilesLink.length > 0) {
|
||||
profileLinkButton = createLinkButton(profilesLink[0], SpanLinkType.Profiles, 'Profiles for this span', 'link');
|
||||
}
|
||||
const sessionLink = links?.filter((link) => link.type === SpanLinkType.Session);
|
||||
if (links && sessionLink && sessionLink.length > 0) {
|
||||
sessionLinkButton = createLinkButton(
|
||||
sessionLink[0],
|
||||
SpanLinkType.Session,
|
||||
'Session for this span',
|
||||
'frontend-observability'
|
||||
);
|
||||
}
|
||||
}
|
||||
const { profileLinkButtons, logLinkButton, sessionLinkButton } = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType,
|
||||
traceToProfilesOptions,
|
||||
timeRange,
|
||||
app,
|
||||
});
|
||||
|
||||
const focusSpanLink = createFocusSpanLink(traceID, spanID);
|
||||
return (
|
||||
@@ -359,7 +313,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
</div>
|
||||
<div className={styles.linkList}>
|
||||
{logLinkButton}
|
||||
{profileLinkButton}
|
||||
{profileLinkButtons}
|
||||
{sessionLinkButton}
|
||||
</div>
|
||||
<Divider spacing={1} />
|
||||
|
||||
+14
@@ -16,6 +16,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createTheme } from '@grafana/data';
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow';
|
||||
@@ -47,12 +48,25 @@ const setup = (propOverrides?: SpanDetailRowProps) => {
|
||||
traceStartTime: 1000,
|
||||
theme: createTheme(),
|
||||
traceFlameGraphs: {},
|
||||
timeRange: {
|
||||
raw: {
|
||||
from: 0,
|
||||
to: 1000000000000,
|
||||
},
|
||||
},
|
||||
...propOverrides,
|
||||
};
|
||||
return render(<UnthemedSpanDetailRow {...(props as SpanDetailRowProps)} />);
|
||||
};
|
||||
|
||||
describe('SpanDetailRow tests', () => {
|
||||
beforeEach(() => {
|
||||
setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
links: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders without exploding', () => {
|
||||
expect(() => setup()).not.toThrow();
|
||||
});
|
||||
|
||||
+7
-1
@@ -16,7 +16,7 @@ import { css } from '@emotion/css';
|
||||
import classNames from 'classnames';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui';
|
||||
@@ -103,6 +103,8 @@ export type SpanDetailRowProps = {
|
||||
traceFlameGraphs: TraceFlameGraphs;
|
||||
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
|
||||
setRedrawListView: (redraw: {}) => void;
|
||||
timeRange: TimeRange;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
|
||||
@@ -146,6 +148,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
|
||||
traceFlameGraphs,
|
||||
setTraceFlameGraphs,
|
||||
setRedrawListView,
|
||||
timeRange,
|
||||
app,
|
||||
} = this.props;
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
@@ -193,6 +197,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
|
||||
traceFlameGraphs={traceFlameGraphs}
|
||||
setTraceFlameGraphs={setTraceFlameGraphs}
|
||||
setRedrawListView={setRedrawListView}
|
||||
timeRange={timeRange}
|
||||
app={app}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRow.Cell>
|
||||
|
||||
+7
-1
@@ -18,7 +18,7 @@ import memoizeOne from 'memoize-one';
|
||||
import * as React from 'react';
|
||||
import { RefObject } from 'react';
|
||||
|
||||
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
@@ -109,6 +109,8 @@ type TVirtualizedTraceViewOwnProps = {
|
||||
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
|
||||
redrawListView: {};
|
||||
setRedrawListView: (redraw: {}) => void;
|
||||
timeRange: TimeRange;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TTraceTimeline;
|
||||
@@ -557,6 +559,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
traceFlameGraphs,
|
||||
setTraceFlameGraphs,
|
||||
setRedrawListView,
|
||||
timeRange,
|
||||
app,
|
||||
} = this.props;
|
||||
const detailState = detailStates.get(spanID);
|
||||
if (!trace || !detailState) {
|
||||
@@ -598,6 +602,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
traceFlameGraphs={traceFlameGraphs}
|
||||
setTraceFlameGraphs={setTraceFlameGraphs}
|
||||
setRedrawListView={setRedrawListView}
|
||||
timeRange={timeRange}
|
||||
app={app}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PureComponent, RefObject } from 'react';
|
||||
|
||||
import { GrafanaTheme2, LinkModel, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { CoreApp, GrafanaTheme2, LinkModel, TimeRange, TraceKeyValuePair, TraceLog } from '@grafana/data';
|
||||
import { SpanBarOptions, TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
@@ -112,6 +112,8 @@ export type TProps = {
|
||||
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
|
||||
redrawListView: {};
|
||||
setRedrawListView: (redraw: {}) => void;
|
||||
timeRange: TimeRange;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum SpanLinkType {
|
||||
Traces = 'trace',
|
||||
Metrics = 'metric',
|
||||
Profiles = 'profile',
|
||||
ProfilesDrilldown = 'profile-drilldown',
|
||||
Session = 'session',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const TracesPanel = ({ data, options, replaceVariables }: PanelProps<Trac
|
||||
focusedSpanId={options.focusedSpanId}
|
||||
createFocusSpanLink={options.createFocusSpanLink}
|
||||
spanFilters={replaceSearchVariables(replaceVariables, options.spanFilters)}
|
||||
timeRange={data.timeRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user