diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 4bd286d4e3b..318a66f145b 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -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 = { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 7229276849e..47c5928ff1c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -514,6 +514,7 @@ export class Explore extends PureComponent { dataFrames={dataFrames} splitOpenFn={this.onSplitOpen('traceView')} scrollElement={this.scrollElement} + timeRange={queryResponse.timeRange} /> ) diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 0f5cac923a1..3f8d46cc5a5 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -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} /> ); @@ -47,6 +48,11 @@ function renderTraceViewNew() { describe('TraceView', () => { beforeAll(() => { + setPluginLinksHook(() => ({ + isLoading: false, + links: [], + })); + setDataSourceSrv({ getInstanceSettings() { return undefined; diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 0366c74ba20..3bd40c6e45f 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -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; 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} /> ) : ( diff --git a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx index ca88947687e..0d1bf7c5c66 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.test.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.test.tsx @@ -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( - {}} /> + {}} timeRange={{} as TimeRange} /> ); return { diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index 68bb08dcad5..4d0e114c865 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -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} /> ); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx new file mode 100644 index 00000000000..07cfff3ff66 --- /dev/null +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx @@ -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(''); + }); +}); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx new file mode 100644 index 00000000000..a8084cd4fec --- /dev/null +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx @@ -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 = ( + + ); + + 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 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 }} + /> + ); +}; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx index d9b3c14f52c..1ad71c04cbd 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.test.tsx @@ -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('', () => { 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('', () => { props.logsToggle.mockReset(); props.logItemToggle.mockReset(); + setPluginLinksHook(() => ({ + isLoading: false, + links: [], + })); + setDataSourceSrv({ getList() { return [pyroSettings]; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 9d6c42fe92c..7a6ec6dbc17 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -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 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) {
{logLinkButton} - {profileLinkButton} + {profileLinkButtons} {sessionLinkButton}
diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx index 90223e8ed2b..6ac58bf1b1f 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.test.tsx @@ -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(); }; describe('SpanDetailRow tests', () => { + beforeEach(() => { + setPluginLinksHook(() => ({ + isLoading: false, + links: [], + })); + }); + it('renders without exploding', () => { expect(() => setup()).not.toThrow(); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx index 799e8cc739b..e8b8c3b4000 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx @@ -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 { @@ -146,6 +148,8 @@ export class UnthemedSpanDetailRow extends PureComponent { traceFlameGraphs, setTraceFlameGraphs, setRedrawListView, + timeRange, + app, } = this.props; const styles = getStyles(theme); return ( @@ -193,6 +197,8 @@ export class UnthemedSpanDetailRow extends PureComponent { traceFlameGraphs={traceFlameGraphs} setTraceFlameGraphs={setTraceFlameGraphs} setRedrawListView={setRedrawListView} + timeRange={timeRange} + app={app} /> diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx index c769f585b77..d84d9e984c5 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -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 ); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx index 0857ad6f588..25885c1b0f7 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx @@ -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 = { diff --git a/public/app/features/explore/TraceView/components/types/links.ts b/public/app/features/explore/TraceView/components/types/links.ts index 00f8f74b6ba..2610e9511cf 100644 --- a/public/app/features/explore/TraceView/components/types/links.ts +++ b/public/app/features/explore/TraceView/components/types/links.ts @@ -9,6 +9,7 @@ export enum SpanLinkType { Traces = 'trace', Metrics = 'metric', Profiles = 'profile', + ProfilesDrilldown = 'profile-drilldown', Session = 'session', Unknown = 'unknown', } diff --git a/public/app/plugins/panel/traces/TracesPanel.tsx b/public/app/plugins/panel/traces/TracesPanel.tsx index b83e64af263..cf6fe0fae97 100644 --- a/public/app/plugins/panel/traces/TracesPanel.tsx +++ b/public/app/plugins/panel/traces/TracesPanel.tsx @@ -52,6 +52,7 @@ export const TracesPanel = ({ data, options, replaceVariables }: PanelProps );