TraceView: Render all links in span details (#101881)
* Render all links in span details * Fix empty links case * Update betterer results * Update devenv and add docs * Update public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix e2e test * Remove .only * Post merge updates --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
+5
-1
@@ -1,5 +1,5 @@
|
||||
// BETTERER RESULTS V2.
|
||||
//
|
||||
//
|
||||
// If this file contains merge conflicts, use `betterer merge` to automatically resolve them:
|
||||
// https://phenomnomnominal.github.io/betterer/docs/results-file/#merge
|
||||
//
|
||||
@@ -4048,6 +4048,10 @@ exports[`better eslint`] = {
|
||||
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
|
||||
@@ -255,6 +255,7 @@ datasources:
|
||||
customMetricsNamespaces: "CWAgent"
|
||||
|
||||
- name: gdev-loki
|
||||
uid: gdev-loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://localhost:3100
|
||||
@@ -313,6 +314,15 @@ datasources:
|
||||
access: proxy
|
||||
url: http://localhost:3200
|
||||
editable: false
|
||||
correlations:
|
||||
- targetUID: gdev-loki
|
||||
label: "Logs (correlation)"
|
||||
description: "Correlation to logs stored in Loki"
|
||||
config:
|
||||
type: query
|
||||
target:
|
||||
expr: "{ job=\"job\" }"
|
||||
field: "traceID"
|
||||
jsonData:
|
||||
tracesToLogsV2:
|
||||
datasourceUid: gdev-loki
|
||||
@@ -323,6 +333,14 @@ datasources:
|
||||
tracesToProfiles:
|
||||
datasourceUid: gdev-pyroscope
|
||||
profileTypeId: "process_cpu:cpu:nanoseconds:cpu:nanoseconds"
|
||||
tracesToMetrics:
|
||||
datasourceUid: gdev-prometheus
|
||||
spanStartTimeShift: '1h'
|
||||
spanEndTimeShift: '-1h'
|
||||
tags: [{ key: 'job' }]
|
||||
queries:
|
||||
- name: 'Metrics'
|
||||
query: 'sum(rate({$$__tags}[5m]))'
|
||||
|
||||
- name: gdev-pyroscope
|
||||
type: grafana-pyroscope-datasource
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Loki editor', () => {
|
||||
test('Autocomplete features should work as expected.', async ({ page }) => {
|
||||
// Go to loki datasource in explore
|
||||
await page.goto(
|
||||
'/explore?schemaVersion=1&panes=%7B%22iap%22:%7B%22datasource%22:%22gdev-loki%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22gdev-loki%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1'
|
||||
'/explore?schemaVersion=1&panes=%7B%22iap%22:%7B%22datasource%22:%22gdev-loki%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22gdev-loki%22%7D,%22editorMode%22:%22builder%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1'
|
||||
);
|
||||
|
||||
const queryEditor = page.getByTestId(e2e.selectors.components.QueryField.container);
|
||||
|
||||
+13
-19
@@ -40,9 +40,7 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
expect(result.props.children).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should create log link button when logs link exists', () => {
|
||||
@@ -57,9 +55,8 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeDefined();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
expect(result.props.children).toHaveLength(1);
|
||||
expect(result.props.children[0].props.link.title).toBe('Logs for this span');
|
||||
});
|
||||
|
||||
it('should create profile link button when profiles link exists', () => {
|
||||
@@ -75,12 +72,11 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
customQuery: false,
|
||||
},
|
||||
timeRange,
|
||||
app: CoreApp.Explore,
|
||||
app: CoreApp.Dashboard,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeDefined();
|
||||
expect(result.sessionLinkButton).toBeNull();
|
||||
expect(result.props.children).toHaveLength(1);
|
||||
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
|
||||
});
|
||||
|
||||
it('should create session link button when session link exists', () => {
|
||||
@@ -95,9 +91,8 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
app: CoreApp.Explore,
|
||||
});
|
||||
|
||||
expect(result.logLinkButton).toBeNull();
|
||||
expect(result.profileLinkButtons).toBeNull();
|
||||
expect(result.sessionLinkButton).toBeDefined();
|
||||
expect(result.props.children).toHaveLength(1);
|
||||
expect(result.props.children[0].props.link.title).toBe('Session for this span');
|
||||
});
|
||||
|
||||
it('should create profile drilldown button when plugin link exists', () => {
|
||||
@@ -126,9 +121,9 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
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);
|
||||
expect(result.props.children).toHaveLength(2);
|
||||
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
|
||||
expect(result.props.children[1].props.link.title).toBe('Open in Profiles Drilldown');
|
||||
});
|
||||
|
||||
it('should not create profile drilldown button when not in Explore', () => {
|
||||
@@ -157,9 +152,8 @@ describe('getSpanDetailLinkButtons', () => {
|
||||
app: CoreApp.Dashboard,
|
||||
});
|
||||
|
||||
expect(result.profileLinkButtons).toBeDefined();
|
||||
// Should only render the original profile link
|
||||
expect(result.profileLinkButtons?.props.children).toBeFalsy();
|
||||
expect(result.props.children).toHaveLength(1);
|
||||
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+140
-102
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, IconName, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data';
|
||||
import { CoreApp, IconName, LinkModel, 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 { DataLinkButton, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
|
||||
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
|
||||
|
||||
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
|
||||
@@ -30,91 +30,122 @@ export type Props = {
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Order in which known link types are shown in the span details
|
||||
* This was added in https://github.com/grafana/grafana/pull/101881 to preserve the order of links
|
||||
* customers might have been used to. This will be revisted in https://github.com/grafana/grafana/issues/101925
|
||||
*/
|
||||
const LINKS_ORDER = [
|
||||
SpanLinkType.Metrics,
|
||||
SpanLinkType.Logs,
|
||||
SpanLinkType.Profiles,
|
||||
SpanLinkType.ProfilesDrilldown,
|
||||
SpanLinkType.Session,
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum number of links to show before moving them to a dropdown
|
||||
*/
|
||||
const MAX_LINKS = 3;
|
||||
|
||||
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 linkToProfiles: SpanLinkDef | undefined;
|
||||
|
||||
let profileLinkButtons = profileLinkButton;
|
||||
if (profileLinkButton) {
|
||||
// ensure we have a profile link
|
||||
const profilesDrilldownPluginId = 'grafana-pyroscope-app';
|
||||
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
|
||||
if (createSpanLink) {
|
||||
const links = (createSpanLink(span) || [])
|
||||
// Linked spans are shown in a separate section
|
||||
.filter((link) => link.type !== SpanLinkType.Traces)
|
||||
.map((link) => {
|
||||
if (link.type === SpanLinkType.Logs) {
|
||||
return createLinkModel(link, SpanLinkType.Logs, 'Logs for this span', 'gf-logs', datasourceType);
|
||||
}
|
||||
if (link.type === SpanLinkType.Profiles && link.title === RelatedProfilesTitle) {
|
||||
linkToProfiles = link;
|
||||
return createLinkModel(link, SpanLinkType.Profiles, 'Profiles for this span', 'link', datasourceType);
|
||||
}
|
||||
if (link.type === SpanLinkType.Session) {
|
||||
return createLinkModel(
|
||||
link,
|
||||
SpanLinkType.Session,
|
||||
'Session for this span',
|
||||
'frontend-observability',
|
||||
datasourceType
|
||||
);
|
||||
}
|
||||
return createLinkModel(link, SpanLinkType.Unknown, link.title || '', 'link', datasourceType);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
// TODO: create SpanLinkDef in createSpanLink (https://github.com/grafana/grafana/issues/101925)
|
||||
if (linkToProfiles && app === CoreApp.Explore) {
|
||||
// ensure we have a profile link
|
||||
const profilesDrilldownPluginId = 'grafana-pyroscope-app';
|
||||
const context = getProfileLinkButtonsContext(span, traceToProfilesOptions, timeRange);
|
||||
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 { links: pluginLinks } = usePluginLinks({ extensionPointId, context, limitPerPlugin: 1 });
|
||||
const link =
|
||||
pluginLinks && pluginLinks.length > 0
|
||||
? pluginLinks.find((link) => link.pluginId === profilesDrilldownPluginId)
|
||||
: null;
|
||||
const label = 'Open in Profiles Drilldown';
|
||||
const appLink: SpanLinkDef = {
|
||||
...linkToProfiles,
|
||||
href: '',
|
||||
onClick: () => {
|
||||
link?.onClick?.();
|
||||
},
|
||||
};
|
||||
links.push(createLinkModel(appLink, SpanLinkType.ProfilesDrilldown, label, 'link', datasourceType));
|
||||
}
|
||||
|
||||
// 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',
|
||||
});
|
||||
links.sort((a, b) => {
|
||||
const aIndex = LINKS_ORDER.indexOf(a.type);
|
||||
const bIndex = LINKS_ORDER.indexOf(b.type);
|
||||
const aValue = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex;
|
||||
const bValue = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex;
|
||||
return aValue - bValue;
|
||||
});
|
||||
|
||||
link.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
profileLinkButtons = (
|
||||
<>
|
||||
{profileLinkButton}
|
||||
{profileDrilldownLinkButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (links.length > MAX_LINKS) {
|
||||
return <DropDownMenu links={links}></DropDownMenu>;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{links.map(({ linkModel, icon, className }, index) => (
|
||||
<DataLinkButton key={index} link={linkModel} buttonProps={{ icon, className }}></DataLinkButton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { profileLinkButtons, logLinkButton, sessionLinkButton };
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const menu = (
|
||||
<Menu>
|
||||
{links.map(({ linkModel }, index) => (
|
||||
<Menu.Item
|
||||
key={index}
|
||||
label={linkModel.title}
|
||||
onClick={(event: React.MouseEvent) => linkModel.onClick?.(event)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown overlay={menu} placement="bottom-start" onVisibleChange={setIsOpen}>
|
||||
<ToolbarButton variant="primary" icon="link" isOpen={isOpen} aria-label="Links">
|
||||
Links
|
||||
</ToolbarButton>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export const getProfileLinkButtonsContext = (
|
||||
@@ -137,40 +168,47 @@ export const getProfileLinkButtonsContext = (
|
||||
return context;
|
||||
};
|
||||
|
||||
const createLinkButton = (
|
||||
type SpanLinkModel = {
|
||||
linkModel: LinkModel;
|
||||
icon: IconName;
|
||||
className?: string;
|
||||
type: SpanLinkType;
|
||||
};
|
||||
|
||||
const createLinkModel = (
|
||||
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',
|
||||
});
|
||||
): SpanLinkModel => {
|
||||
return {
|
||||
icon,
|
||||
className,
|
||||
type,
|
||||
linkModel: {
|
||||
...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 }}
|
||||
/>
|
||||
);
|
||||
if (link.onClick) {
|
||||
link.onClick?.(event);
|
||||
} else {
|
||||
locationService.push(link.href);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+2
-6
@@ -292,7 +292,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const { profileLinkButtons, logLinkButton, sessionLinkButton } = getSpanDetailLinkButtons({
|
||||
const linksComponent = getSpanDetailLinkButtons({
|
||||
span,
|
||||
createSpanLink,
|
||||
datasourceType,
|
||||
@@ -312,11 +312,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
<LabeledList className={styles.list} divider={true} items={overviewItems} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.linkList}>
|
||||
{logLinkButton}
|
||||
{profileLinkButtons}
|
||||
{sessionLinkButton}
|
||||
</div>
|
||||
<div className={styles.linkList}>{linksComponent}</div>
|
||||
<Divider spacing={1} />
|
||||
<div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user