diff --git a/public/app/features/explore/TraceView/components/types/links.ts b/public/app/features/explore/TraceView/components/types/links.ts
index 3a2e5f05e0e..0ffe2ec5ec1 100644
--- a/public/app/features/explore/TraceView/components/types/links.ts
+++ b/public/app/features/explore/TraceView/components/types/links.ts
@@ -8,6 +8,7 @@ export enum SpanLinkType {
Logs = 'log',
Traces = 'trace',
Metrics = 'metric',
+ Profiles = 'profile',
Unknown = 'unknown',
}
diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts
index e4517360b16..743e3290c1f 100644
--- a/public/app/features/explore/TraceView/createSpanLink.test.ts
+++ b/public/app/features/explore/TraceView/createSpanLink.test.ts
@@ -5,8 +5,9 @@ import {
SupportedTransformationType,
DataLinkConfigOrigin,
FieldType,
+ DataFrame,
} from '@grafana/data';
-import { DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime';
+import { config, DataSourceSrv, setDataSourceSrv, setTemplateSrv } from '@grafana/runtime';
import { TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -19,7 +20,43 @@ import { SpanLinkType } from './components/types/links';
import { createSpanLinkFactory } from './createSpanLink';
const dummyTraceData = { duration: 10, traceID: 'trace1', traceName: 'test trace' } as unknown as Trace;
-const dummyDataFrame = createDataFrame({ fields: [{ name: 'traceId', values: ['trace1'] }] });
+const dummyDataFrame = createDataFrame({
+ fields: [
+ { name: 'traceId', values: ['trace1'] },
+ { name: 'spanID', values: ['testSpanId'] },
+ ],
+});
+const dummyDataFrameForProfiles = createDataFrame({
+ fields: [
+ { name: 'traceId', values: ['trace1'] },
+ { name: 'spanID', values: ['testSpanId'] },
+ {
+ name: 'tags',
+ config: {
+ links: [
+ {
+ internal: {
+ query: {
+ labelSelector: '{${__tags}}',
+ groupBy: [],
+ profileTypeId: '',
+ queryType: 'profile',
+ spanSelector: ['${__span.spanId}'],
+ refId: '',
+ },
+ datasourceUid: 'pyroscopeUid',
+ datasourceName: 'pyroscope',
+ },
+ url: '',
+ title: 'Test',
+ origin: DataLinkConfigOrigin.Datasource,
+ },
+ ],
+ },
+ values: [{ key: 'test', value: 'test' }],
+ },
+ ],
+});
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
@@ -1229,6 +1266,206 @@ describe('createSpanLinkFactory', () => {
);
});
});
+
+ describe('should return pyroscope link', () => {
+ beforeAll(() => {
+ setDataSourceSrv({
+ getInstanceSettings() {
+ return {
+ uid: 'pyroscopeUid',
+ name: 'pyroscope',
+ type: 'grafana-pyroscope-datasource',
+ } as unknown as DataSourceInstanceSettings;
+ },
+ } as unknown as DataSourceSrv);
+
+ setLinkSrv(new LinkSrv());
+ setTemplateSrv(new TemplateSrv());
+ config.featureToggles.traceToProfiles = true;
+ });
+
+ it('with default keys when tags not configured', () => {
+ const createLink = setupSpanLinkFactory({}, '', dummyDataFrameForProfiles);
+ expect(createLink).toBeDefined();
+ const links = createLink!(createTraceSpan());
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Profiles);
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service_namespace=\\"namespace1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}'
+ )}`
+ );
+ });
+
+ it('with tags that passed in and without tags that are not in the span', () => {
+ const createLink = setupSpanLinkFactory(
+ {
+ tags: [{ key: 'ip' }, { key: 'newTag' }],
+ },
+ '',
+ dummyDataFrameForProfiles
+ );
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'hostname', value: 'hostname1' },
+ { key: 'ip', value: '192.168.0.1' },
+ ],
+ },
+ })
+ );
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Profiles);
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}'
+ )}`
+ );
+ });
+
+ it('from tags and process tags as well', () => {
+ const createLink = setupSpanLinkFactory(
+ {
+ tags: [{ key: 'ip' }, { key: 'host' }],
+ },
+ '',
+ dummyDataFrameForProfiles
+ );
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'hostname', value: 'hostname1' },
+ { key: 'ip', value: '192.168.0.1' },
+ ],
+ },
+ })
+ );
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Profiles);
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}'
+ )}`
+ );
+ });
+
+ it('creates link from dataFrame', () => {
+ const splitOpenFn = jest.fn();
+ const createLink = createSpanLinkFactory({
+ splitOpenFn,
+ dataFrame: createDataFrame({
+ fields: [
+ { name: 'traceID', values: ['testTraceId'] },
+ {
+ name: 'spanID',
+ config: { links: [{ title: 'link', url: '${__data.fields.spanID}' }] },
+ values: ['testSpanId'],
+ },
+ ],
+ }),
+ trace: dummyTraceData,
+ });
+ expect(createLink).toBeDefined();
+ const links = createLink!(createTraceSpan());
+
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Unknown);
+ expect(linkDef!.href).toBe('testSpanId');
+ });
+
+ it('handles renamed tags', () => {
+ const createLink = setupSpanLinkFactory(
+ {
+ tags: [
+ { key: 'service.name', value: 'service' },
+ { key: 'k8s.pod.name', value: 'pod' },
+ ],
+ },
+ '',
+ dummyDataFrameForProfiles
+ );
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'service.name', value: 'serviceName' },
+ { key: 'k8s.pod.name', value: 'podName' },
+ ],
+ },
+ })
+ );
+
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Profiles);
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}'
+ )}`
+ );
+ });
+
+ it('handles incomplete renamed tags', () => {
+ const createLink = setupSpanLinkFactory(
+ {
+ tags: [
+ { key: 'service.name', value: '' },
+ { key: 'k8s.pod.name', value: 'pod' },
+ ],
+ },
+ '',
+ dummyDataFrameForProfiles
+ );
+ expect(createLink).toBeDefined();
+ const links = createLink!(
+ createTraceSpan({
+ process: {
+ serviceName: 'service',
+ tags: [
+ { key: 'service.name', value: 'serviceName' },
+ { key: 'k8s.pod.name', value: 'podName' },
+ ],
+ },
+ })
+ );
+
+ const linkDef = links?.[0];
+ expect(linkDef).toBeDefined();
+ expect(linkDef?.type).toBe(SpanLinkType.Profiles);
+ expect(linkDef!.href).toBe(
+ `/explore?left=${encodeURIComponent(
+ '{"range":{"from":"1602637140000","to":"1602637261000"},"datasource":"pyroscopeUid","queries":[{"labelSelector":"{service.name=\\"serviceName\\", pod=\\"podName\\"}","groupBy":[],"profileTypeId":"","queryType":"profile","spanSelector":["6605c7b08e715d6c"],"refId":""}]}'
+ )}`
+ );
+ });
+
+ it('interpolates span intrinsics', () => {
+ const createLink = setupSpanLinkFactory(
+ {
+ tags: [{ key: 'name', value: 'spanName' }],
+ },
+ '',
+ dummyDataFrameForProfiles
+ );
+ expect(createLink).toBeDefined();
+ const links = createLink!(createTraceSpan());
+ expect(links).toBeDefined();
+ expect(links![0].type).toBe(SpanLinkType.Profiles);
+ expect(decodeURIComponent(links![0].href)).toContain('spanName=\\"operation\\"');
+ });
+ });
});
describe('dataFrame links', () => {
@@ -1272,7 +1509,11 @@ describe('dataFrame links', () => {
});
});
-function setupSpanLinkFactory(options: Partial
= {}, datasourceUid = 'lokiUid') {
+function setupSpanLinkFactory(
+ options: Partial = {},
+ datasourceUid = 'lokiUid',
+ dummyDataFrameForProfiles?: DataFrame
+) {
const splitOpenFn = jest.fn();
return createSpanLinkFactory({
splitOpenFn,
@@ -1281,13 +1522,18 @@ function setupSpanLinkFactory(options: Partial = {}, datas
datasourceUid,
...options,
},
+ traceToProfilesOptions: {
+ customQuery: false,
+ datasourceUid: 'pyroscopeUid',
+ ...options,
+ },
createFocusSpanLink: (traceId, spanId) => {
return {
href: `${traceId}-${spanId}`,
} as unknown as LinkModel;
},
trace: dummyTraceData,
- dataFrame: dummyDataFrame,
+ dataFrame: dummyDataFrameForProfiles ? dummyDataFrameForProfiles : dummyDataFrame,
});
}
@@ -1308,6 +1554,10 @@ function createTraceSpan(overrides: Partial = {}) {
key: 'host',
value: 'host',
},
+ {
+ key: 'pyroscope.profiling.enabled',
+ value: 'hdgfljn23u982nj',
+ },
],
process: {
serviceName: 'test service',
diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx
index 930edd74c89..4de87bdd33e 100644
--- a/public/app/features/explore/TraceView/createSpanLink.tsx
+++ b/public/app/features/explore/TraceView/createSpanLink.tsx
@@ -19,6 +19,7 @@ import { DataQuery } from '@grafana/schema';
import { Icon } from '@grafana/ui';
import { TraceToLogsOptionsV2, TraceToLogsTag } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { TraceToMetricQuery, TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings';
+import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
@@ -37,6 +38,7 @@ export function createSpanLinkFactory({
splitOpenFn,
traceToLogsOptions,
traceToMetricsOptions,
+ traceToProfilesOptions,
dataFrame,
createFocusSpanLink,
trace,
@@ -44,6 +46,7 @@ export function createSpanLinkFactory({
splitOpenFn: SplitOpen;
traceToLogsOptions?: TraceToLogsOptionsV2;
traceToMetricsOptions?: TraceToMetricsOptions;
+ traceToProfilesOptions?: TraceToProfilesOptions;
dataFrame?: DataFrame;
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel;
trace: Trace;
@@ -72,17 +75,26 @@ export function createSpanLinkFactory({
scopedVars = {
...scopedVars,
...scopedVarsFromSpan(span),
+ ...scopedVarsFromTags(span, traceToProfilesOptions),
};
// We should be here only if there are some links in the dataframe
const fields = dataFrame.fields.filter((f) => Boolean(f.config.links?.length))!;
try {
+ let profilesDataSourceSettings: DataSourceInstanceSettings | undefined;
+ if (traceToProfilesOptions?.datasourceUid) {
+ profilesDataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToProfilesOptions.datasourceUid);
+ }
+ const hasConfiguredPyroscopeDS = profilesDataSourceSettings?.type === 'grafana-pyroscope-datasource';
+ const hasPyroscopeProfile = span.tags.filter((tag) => tag.key === 'pyroscope.profiling.enabled').length > 0;
+ const shouldCreatePyroscopeLink = hasConfiguredPyroscopeDS && hasPyroscopeProfile;
+
let links: ExploreFieldLinkModel[] = [];
fields.forEach((field) => {
const fieldLinksForExplore = getFieldLinksForExplore({
field,
rowIndex: span.dataFrameRowIndex!,
splitOpenFn,
- range: getTimeRangeFromSpan(span),
+ range: getTimeRangeFromSpan(span, undefined, undefined, shouldCreatePyroscopeLink),
dataFrame,
vars: scopedVars,
});
@@ -96,7 +108,7 @@ export function createSpanLinkFactory({
onClick: link.onClick,
content: ,
field: link.origin,
- type: SpanLinkType.Unknown,
+ type: shouldCreatePyroscopeLink ? SpanLinkType.Profiles : SpanLinkType.Unknown,
};
});
@@ -115,10 +127,14 @@ export function createSpanLinkFactory({
/**
* Default keys to use when there are no configured tags.
*/
-const defaultKeys = ['cluster', 'hostname', 'namespace', 'pod', 'service.name', 'service.namespace'].map((k) => ({
- key: k,
- value: k.includes('.') ? k.replace('.', '_') : undefined,
-}));
+const formatDefaultKeys = (keys: string[]) => {
+ return keys.map((k) => ({
+ key: k,
+ value: k.includes('.') ? k.replace('.', '_') : undefined,
+ }));
+};
+const defaultKeys = formatDefaultKeys(['cluster', 'hostname', 'namespace', 'pod', 'service.name', 'service.namespace']);
+const defaultProfilingKeys = formatDefaultKeys(['service.name', 'service.namespace']);
function legacyCreateSpanLinkFactory(
splitOpenFn: SplitOpen,
@@ -514,16 +530,19 @@ function getFormattedTags(
function getTimeRangeFromSpan(
span: TraceSpan,
timeShift: { startMs: number; endMs: number } = { startMs: 0, endMs: 0 },
- isSplunkDS = false
+ isSplunkDS = false,
+ shouldCreatePyroscopeLink = false
): TimeRange {
- const adjustedStartTime = Math.floor(span.startTime / 1000 + timeShift.startMs);
- const from = dateTime(adjustedStartTime);
+ let adjustedStartTime = Math.floor(span.startTime / 1000 + timeShift.startMs);
const spanEndMs = (span.startTime + span.duration) / 1000;
let adjustedEndTime = Math.floor(spanEndMs + timeShift.endMs);
// Splunk requires a time interval of >= 1s, rather than >=1ms like Loki timerange in below elseif block
if (isSplunkDS && adjustedEndTime - adjustedStartTime < 1000) {
adjustedEndTime = adjustedStartTime + 1000;
+ } else if (shouldCreatePyroscopeLink) {
+ adjustedStartTime = adjustedStartTime - 60000;
+ adjustedEndTime = adjustedEndTime + 60000;
} else if (adjustedStartTime === adjustedEndTime) {
// Because we can only pass milliseconds in the url we need to check if they equal.
// We need end time to be later than start time
@@ -531,6 +550,7 @@ function getTimeRangeFromSpan(
}
const to = dateTime(adjustedEndTime);
+ const from = dateTime(adjustedStartTime);
// Beware that public/app/features/explore/state/main.ts SplitOpen fn uses the range from here. No matter what is in the url.
return {
@@ -617,3 +637,27 @@ function scopedVarsFromSpan(span: TraceSpan): ScopedVars {
},
};
}
+
+/**
+ * Variables from tags that can be used in the query
+ * @param span
+ */
+function scopedVarsFromTags(span: TraceSpan, traceToProfilesOptions: TraceToProfilesOptions | undefined): ScopedVars {
+ let tags: ScopedVars = {};
+
+ if (traceToProfilesOptions) {
+ const profileTags =
+ traceToProfilesOptions.tags && traceToProfilesOptions.tags.length > 0
+ ? traceToProfilesOptions.tags
+ : defaultProfilingKeys;
+
+ tags = {
+ __tags: {
+ text: 'Tags',
+ value: getFormattedTags(span, profileTags),
+ },
+ };
+ }
+
+ return tags;
+}
diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx
index 17b6608fbb6..f04bb77b7de 100644
--- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx
+++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/ProfileTypesCascader.tsx
@@ -10,6 +10,7 @@ type Props = {
profileTypes?: ProfileTypeMessage[];
onChange: (value: string) => void;
placeholder?: string;
+ width?: number;
};
export function ProfileTypesCascader(props: Props) {
@@ -25,6 +26,7 @@ export function ProfileTypesCascader(props: Props) {
onSelect={props.onChange}
options={cascaderOptions}
changeOnSelect={false}
+ width={props.width ?? 26}
/>
);
}
diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx
index 50c432d23a3..94ade2699b3 100644
--- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx
+++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import React from 'react';
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { useStyles2, RadioButtonGroup, MultiSelect, Input } from '@grafana/ui';
import { QueryOptionGroup } from '../../prometheus/querybuilder/shared/QueryOptionGroup';
@@ -83,6 +84,21 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
}}
/>
+ {config.featureToggles.traceToProfiles && (
+ Sets the span ID from which to search for profiles.>}>
+ ) => {
+ onQueryChange({
+ ...query,
+ spanSelector: event.currentTarget.value !== '' ? [event.currentTarget.value] : [],
+ });
+ }}
+ />
+
+ )}
Sets the maximum number of nodes to return in the flamegraph.>}>
;
}
export const defaultGrafanaPyroscope: Partial = {
groupBy: [],
labelSelector: '{}',
+ spanSelector: [],
};
diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx
index 43d5cf502cf..5b02f520b87 100644
--- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx
+++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx
@@ -18,6 +18,7 @@ import { Divider } from 'app/core/components/Divider';
import { NodeGraphSection } from 'app/core/components/NodeGraphSettings';
import { TraceToLogsSection } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { TraceToMetricsSection } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings';
+import { TraceToProfilesSection } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings';
import { SpanBarSection } from 'app/features/explore/TraceView/components/settings/SpanBarSettings';
import { LokiSearchSettings } from './LokiSearchSettings';
@@ -60,6 +61,13 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => {
>
) : null}
+ {config.featureToggles.traceToProfiles && (
+ <>
+
+
+ >
+ )}
+
,
+ nodeGraph = false
+): DataQueryResponse {
const frame = response.data[0];
if (!frame) {
return emptyDataQueryResponse;
}
+ // Get profiles links
+ if (config.featureToggles.traceToProfiles) {
+ const traceToProfilesData: TraceToProfilesData | undefined = instanceSettings?.jsonData;
+ const traceToProfilesOptions = traceToProfilesData?.tracesToProfiles;
+ let profilesDataSourceSettings: DataSourceInstanceSettings | undefined;
+ if (traceToProfilesOptions?.datasourceUid) {
+ profilesDataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToProfilesOptions.datasourceUid);
+ }
+
+ if (traceToProfilesOptions && profilesDataSourceSettings) {
+ const customQuery = traceToProfilesOptions.customQuery ? traceToProfilesOptions.query : undefined;
+ const dataLink: DataLink = {
+ title: RelatedProfilesTitle,
+ url: '',
+ internal: {
+ datasourceUid: profilesDataSourceSettings.uid,
+ datasourceName: profilesDataSourceSettings.name,
+ query: {
+ labelSelector: customQuery ? customQuery : '{${__tags}}',
+ groupBy: [],
+ profileTypeId: traceToProfilesOptions.profileTypeId ?? '',
+ queryType: 'profile',
+ spanSelector: ['${__span.spanId}'],
+ refId: 'profile',
+ },
+ },
+ origin: DataLinkConfigOrigin.Datasource,
+ };
+
+ frame.fields.forEach((field: Field) => {
+ if (field.name === 'tags') {
+ field.config.links = [dataLink];
+ }
+ });
+ }
+ }
+
let data = [...response.data];
if (nodeGraph) {
data.push(...createGraphFrames(toDataFrame(frame)));