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:
Joey
2025-03-05 15:47:44 +01:00
committed by GitHub
co-authored by Piotr Jamróz
parent c23bb36956
commit 31e92ba720
16 changed files with 490 additions and 72 deletions
@@ -185,6 +185,7 @@ export enum PluginExtensionPoints {
DataSourceConfig = 'grafana/datasources/config', DataSourceConfig = 'grafana/datasources/config',
ExploreToolbarAction = 'grafana/explore/toolbar/action', ExploreToolbarAction = 'grafana/explore/toolbar/action',
UserProfileTab = 'grafana/user/profile/tab', UserProfileTab = 'grafana/user/profile/tab',
TraceViewDetails = 'grafana/traceview/details',
} }
export type PluginExtensionPanelContext = { export type PluginExtensionPanelContext = {
+1
View File
@@ -514,6 +514,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
dataFrames={dataFrames} dataFrames={dataFrames}
splitOpenFn={this.onSplitOpen('traceView')} splitOpenFn={this.onSplitOpen('traceView')}
scrollElement={this.scrollElement} scrollElement={this.scrollElement}
timeRange={queryResponse.timeRange}
/> />
</ContentOutlineItem> </ContentOutlineItem>
) )
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event';
import { createRef } from 'react'; import { createRef } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { DataFrame, MutableDataFrame } from '@grafana/data'; import { DataFrame, MutableDataFrame, TimeRange } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
@@ -25,6 +25,7 @@ function getTraceView(frames: DataFrame[]) {
traceProp={transformDataFrames(frames[0])!} traceProp={transformDataFrames(frames[0])!}
datasource={undefined} datasource={undefined}
topOfViewRef={topOfViewRef} topOfViewRef={topOfViewRef}
timeRange={{} as TimeRange}
/> />
</Provider> </Provider>
); );
@@ -47,6 +48,11 @@ function renderTraceViewNew() {
describe('TraceView', () => { describe('TraceView', () => {
beforeAll(() => { beforeAll(() => {
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
setDataSourceSrv({ setDataSourceSrv({
getInstanceSettings() { getInstanceSettings() {
return undefined; return undefined;
@@ -13,6 +13,7 @@ import {
LinkModel, LinkModel,
mapInternalLinkToExplore, mapInternalLinkToExplore,
SplitOpen, SplitOpen,
TimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { getTraceToLogsOptions, TraceToMetricsData, TraceToProfilesData } from '@grafana/o11y-ds-frontend'; import { getTraceToLogsOptions, TraceToMetricsData, TraceToProfilesData } from '@grafana/o11y-ds-frontend';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
@@ -67,6 +68,7 @@ type Props = {
focusedSpanId?: string; focusedSpanId?: string;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>; createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
spanFilters?: SearchProps; spanFilters?: SearchProps;
timeRange: TimeRange;
}; };
export function TraceView(props: Props) { export function TraceView(props: Props) {
@@ -238,6 +240,8 @@ export function TraceView(props: Props) {
setTraceFlameGraphs={setTraceFlameGraphs} setTraceFlameGraphs={setTraceFlameGraphs}
redrawListView={redrawListView} redrawListView={redrawListView}
setRedrawListView={setRedrawListView} 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 userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { TimeRange } from '@grafana/data';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
import { frameOld } from './TraceView.test'; import { frameOld } from './TraceView.test';
@@ -11,6 +13,7 @@ jest.mock('@grafana/runtime', () => {
return { return {
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(), reportInteraction: jest.fn(),
usePluginLinks: jest.fn().mockReturnValue({ isLoading: false, links: [] }),
}; };
}); });
@@ -19,7 +22,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
const { container, baseElement } = render( const { container, baseElement } = render(
<Provider store={store}> <Provider store={store}>
<TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} /> <TraceViewContainer exploreId="left" dataFrames={frames} splitOpenFn={() => {}} timeRange={{} as TimeRange} />
</Provider> </Provider>
); );
return { return {
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { DataFrame, SplitOpen } from '@grafana/data'; import { DataFrame, SplitOpen, TimeRange } from '@grafana/data';
import { PanelChrome } from '@grafana/ui'; import { PanelChrome } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types'; import { StoreState, useSelector } from 'app/types';
@@ -12,12 +12,13 @@ interface Props {
splitOpenFn: SplitOpen; splitOpenFn: SplitOpen;
exploreId: string; exploreId: string;
scrollElement?: Element; scrollElement?: Element;
timeRange: TimeRange;
} }
export function TraceViewContainer(props: Props) { export function TraceViewContainer(props: Props) {
// At this point we only show single trace // At this point we only show single trace
const frame = props.dataFrames[0]; 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 traceProp = useMemo(() => transformDataFrames(frame), [frame]);
const datasource = useSelector( const datasource = useSelector(
(state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined (state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined
@@ -36,6 +37,7 @@ export function TraceViewContainer(props: Props) {
scrollElement={scrollElement} scrollElement={scrollElement}
traceProp={traceProp} traceProp={traceProp}
datasource={datasource} datasource={datasource}
timeRange={timeRange}
/> />
</PanelChrome> </PanelChrome>
); );
@@ -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('');
});
});
@@ -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 }}
/>
);
};
@@ -19,7 +19,7 @@ import userEvent from '@testing-library/user-event';
import { createDataFrame, DataSourceInstanceSettings } from '@grafana/data'; import { createDataFrame, DataSourceInstanceSettings } from '@grafana/data';
import { data } from '@grafana/flamegraph'; import { data } from '@grafana/flamegraph';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; import { DataSourceSrv, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import traceGenerator from '../../demo/trace-generators'; import traceGenerator from '../../demo/trace-generators';
@@ -70,6 +70,12 @@ describe('<SpanDetail>', () => {
createFocusSpanLink: jest.fn().mockReturnValue({}), createFocusSpanLink: jest.fn().mockReturnValue({}),
traceFlameGraphs: { [span.spanID]: createDataFrame(data) }, traceFlameGraphs: { [span.spanID]: createDataFrame(data) },
setRedrawListView: jest.fn(), setRedrawListView: jest.fn(),
timeRange: {
raw: {
from: 0,
to: 1000000000000,
},
},
}; };
span.tags = [ span.tags = [
@@ -156,6 +162,11 @@ describe('<SpanDetail>', () => {
props.logsToggle.mockReset(); props.logsToggle.mockReset();
props.logItemToggle.mockReset(); props.logItemToggle.mockReset();
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
setDataSourceSrv({ setDataSourceSrv({
getList() { getList() {
return [pyroSettings]; return [pyroSettings];
@@ -15,29 +15,26 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { SpanStatusCode } from '@opentelemetry/api'; import { SpanStatusCode } from '@opentelemetry/api';
import cx from 'classnames'; import cx from 'classnames';
import * as React from 'react';
import { import {
CoreApp,
DataFrame, DataFrame,
dateTimeFormat, dateTimeFormat,
GrafanaTheme2, GrafanaTheme2,
IconName,
LinkModel, LinkModel,
TimeRange,
TraceKeyValuePair, TraceKeyValuePair,
TraceLog, TraceLog,
} from '@grafana/data'; } from '@grafana/data';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
import { DataLinkButton, Divider, Icon, TextArea, useStyles2 } from '@grafana/ui'; import { Divider, Icon, TextArea, useStyles2 } from '@grafana/ui';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import { autoColor } from '../../Theme'; import { autoColor } from '../../Theme';
import LabeledList from '../../common/LabeledList'; import LabeledList from '../../common/LabeledList';
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE } from '../../constants/span'; import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE } from '../../constants/span';
import { SpanLinkFunc, TNil } from '../../types'; import { SpanLinkFunc, TNil } from '../../types';
import { SpanLinkDef, SpanLinkType } from '../../types/links';
import { TraceLink, TraceSpan, TraceSpanReference } from '../../types/trace'; import { TraceLink, TraceSpan, TraceSpanReference } from '../../types/trace';
import { formatDuration } from '../utils'; import { formatDuration } from '../utils';
@@ -46,6 +43,7 @@ import AccordianLogs from './AccordianLogs';
import AccordianReferences from './AccordianReferences'; import AccordianReferences from './AccordianReferences';
import AccordianText from './AccordianText'; import AccordianText from './AccordianText';
import DetailState from './DetailState'; import DetailState from './DetailState';
import { getSpanDetailLinkButtons } from './SpanDetailLinkButtons';
import SpanFlameGraph from './SpanFlameGraph'; import SpanFlameGraph from './SpanFlameGraph';
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
@@ -168,6 +166,8 @@ export type SpanDetailProps = {
traceFlameGraphs: TraceFlameGraphs; traceFlameGraphs: TraceFlameGraphs;
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void; setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
setRedrawListView: (redraw: {}) => void; setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
}; };
export default function SpanDetail(props: SpanDetailProps) { export default function SpanDetail(props: SpanDetailProps) {
@@ -193,6 +193,8 @@ export default function SpanDetail(props: SpanDetailProps) {
setTraceFlameGraphs, setTraceFlameGraphs,
traceToProfilesOptions, traceToProfilesOptions,
setRedrawListView, setRedrawListView,
timeRange,
app,
} = props; } = props;
const { const {
isTagsOpen, isTagsOpen,
@@ -289,62 +291,14 @@ export default function SpanDetail(props: SpanDetailProps) {
}); });
} }
const createLinkButton = (link: SpanLinkDef, type: SpanLinkType, title: string, icon: IconName) => { const { profileLinkButtons, logLinkButton, sessionLinkButton } = getSpanDetailLinkButtons({
return ( span,
<DataLinkButton createSpanLink,
link={{ datasourceType,
...link, traceToProfilesOptions,
title: title, timeRange,
target: '_blank', app,
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 focusSpanLink = createFocusSpanLink(traceID, spanID); const focusSpanLink = createFocusSpanLink(traceID, spanID);
return ( return (
@@ -359,7 +313,7 @@ export default function SpanDetail(props: SpanDetailProps) {
</div> </div>
<div className={styles.linkList}> <div className={styles.linkList}>
{logLinkButton} {logLinkButton}
{profileLinkButton} {profileLinkButtons}
{sessionLinkButton} {sessionLinkButton}
</div> </div>
<Divider spacing={1} /> <Divider spacing={1} />
@@ -16,6 +16,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createTheme } from '@grafana/data'; import { createTheme } from '@grafana/data';
import { setPluginLinksHook } from '@grafana/runtime';
import DetailState from './SpanDetail/DetailState'; import DetailState from './SpanDetail/DetailState';
import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow'; import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow';
@@ -47,12 +48,25 @@ const setup = (propOverrides?: SpanDetailRowProps) => {
traceStartTime: 1000, traceStartTime: 1000,
theme: createTheme(), theme: createTheme(),
traceFlameGraphs: {}, traceFlameGraphs: {},
timeRange: {
raw: {
from: 0,
to: 1000000000000,
},
},
...propOverrides, ...propOverrides,
}; };
return render(<UnthemedSpanDetailRow {...(props as SpanDetailRowProps)} />); return render(<UnthemedSpanDetailRow {...(props as SpanDetailRowProps)} />);
}; };
describe('SpanDetailRow tests', () => { describe('SpanDetailRow tests', () => {
beforeEach(() => {
setPluginLinksHook(() => ({
isLoading: false,
links: [],
}));
});
it('renders without exploding', () => { it('renders without exploding', () => {
expect(() => setup()).not.toThrow(); expect(() => setup()).not.toThrow();
}); });
@@ -16,7 +16,7 @@ import { css } from '@emotion/css';
import classNames from 'classnames'; import classNames from 'classnames';
import { PureComponent } from 'react'; 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 { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui'; import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui';
@@ -103,6 +103,8 @@ export type SpanDetailRowProps = {
traceFlameGraphs: TraceFlameGraphs; traceFlameGraphs: TraceFlameGraphs;
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void; setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
setRedrawListView: (redraw: {}) => void; setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
}; };
export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> { export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
@@ -146,6 +148,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
traceFlameGraphs, traceFlameGraphs,
setTraceFlameGraphs, setTraceFlameGraphs,
setRedrawListView, setRedrawListView,
timeRange,
app,
} = this.props; } = this.props;
const styles = getStyles(theme); const styles = getStyles(theme);
return ( return (
@@ -193,6 +197,8 @@ export class UnthemedSpanDetailRow extends PureComponent<SpanDetailRowProps> {
traceFlameGraphs={traceFlameGraphs} traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs} setTraceFlameGraphs={setTraceFlameGraphs}
setRedrawListView={setRedrawListView} setRedrawListView={setRedrawListView}
timeRange={timeRange}
app={app}
/> />
</div> </div>
</TimelineRow.Cell> </TimelineRow.Cell>
@@ -18,7 +18,7 @@ import memoizeOne from 'memoize-one';
import * as React from 'react'; import * as React from 'react';
import { RefObject } 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 { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
@@ -109,6 +109,8 @@ type TVirtualizedTraceViewOwnProps = {
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void; setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
redrawListView: {}; redrawListView: {};
setRedrawListView: (redraw: {}) => void; setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
}; };
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TTraceTimeline; export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TTraceTimeline;
@@ -557,6 +559,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
traceFlameGraphs, traceFlameGraphs,
setTraceFlameGraphs, setTraceFlameGraphs,
setRedrawListView, setRedrawListView,
timeRange,
app,
} = this.props; } = this.props;
const detailState = detailStates.get(spanID); const detailState = detailStates.get(spanID);
if (!trace || !detailState) { if (!trace || !detailState) {
@@ -598,6 +602,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
traceFlameGraphs={traceFlameGraphs} traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs} setTraceFlameGraphs={setTraceFlameGraphs}
setRedrawListView={setRedrawListView} setRedrawListView={setRedrawListView}
timeRange={timeRange}
app={app}
/> />
</div> </div>
); );
@@ -15,7 +15,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { PureComponent, RefObject } from 'react'; 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 { SpanBarOptions, TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
@@ -112,6 +112,8 @@ export type TProps = {
setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void; setTraceFlameGraphs: (flameGraphs: TraceFlameGraphs) => void;
redrawListView: {}; redrawListView: {};
setRedrawListView: (redraw: {}) => void; setRedrawListView: (redraw: {}) => void;
timeRange: TimeRange;
app: CoreApp;
}; };
type State = { type State = {
@@ -9,6 +9,7 @@ export enum SpanLinkType {
Traces = 'trace', Traces = 'trace',
Metrics = 'metric', Metrics = 'metric',
Profiles = 'profile', Profiles = 'profile',
ProfilesDrilldown = 'profile-drilldown',
Session = 'session', Session = 'session',
Unknown = 'unknown', Unknown = 'unknown',
} }
@@ -52,6 +52,7 @@ export const TracesPanel = ({ data, options, replaceVariables }: PanelProps<Trac
focusedSpanId={options.focusedSpanId} focusedSpanId={options.focusedSpanId}
createFocusSpanLink={options.createFocusSpanLink} createFocusSpanLink={options.createFocusSpanLink}
spanFilters={replaceSearchVariables(replaceVariables, options.spanFilters)} spanFilters={replaceSearchVariables(replaceVariables, options.spanFilters)}
timeRange={data.timeRange}
/> />
</div> </div>
); );