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>
pull/102875/head
Piotr Jamróz 10 months ago committed by GitHub
parent ea2a9ee395
commit 1a0478cd76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 18
      devenv/datasources.yaml
  3. 2
      e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/lokiEditor.spec.ts
  4. 32
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx
  5. 246
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx
  6. 8
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx

@ -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);

@ -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');
});
});

@ -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',
});
link.onClick();
}
}}
>
{label}
</Button>
);
profileLinkButtons = (
<>
{profileLinkButton}
{profileDrilldownLinkButton}
</>
);
}
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;
});
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',
});
if (link.onClick) {
link.onClick?.(event);
} else {
locationService.push(link.href);
}
},
}}
buttonProps={{ icon, className }}
/>
);
): 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);
}
},
},
};
};

@ -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>

Loading…
Cancel
Save