TraceView: Display event names of a span (#91382)

* Display event name of a span

* Clean up

* Retrigger the build

* Show colon only when there are fields to display

* Rollback

* Use event name when exporting to OTLP

* Allow filtering spans by event name

* Show duration as a key/value pair

* Update betterer report (we do not translate panels that are planned to be externalized)

* Fix tests after changing how duration is rendered

* Handle long names

* Test handling long names

* Make parenthesis gray

* Fix a test

* Fix linting

* Fix tests

* Update label
This commit is contained in:
Piotr Jamróz 2024-09-03 11:18:50 +02:00 committed by GitHub
parent 1df622641c
commit d7d22bbbb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 157 additions and 35 deletions

View File

@ -4096,7 +4096,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

View File

@ -13,6 +13,7 @@ export type TraceLog = {
// Millisecond epoch time
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceSpanReference = {

View File

@ -22,6 +22,7 @@ type TraceLog struct {
// Millisecond epoch time
Timestamp float64 `json:"timestamp"`
Fields []*KeyValue `json:"fields"`
Name string `json:"name,omitempty"`
}
type TraceReference struct {
@ -260,12 +261,6 @@ func spanEventsToLogs(events ptrace.SpanEventSlice) []*TraceLog {
for i := 0; i < events.Len(); i++ {
event := events.At(i)
fields := make([]*KeyValue, 0, event.Attributes().Len()+1)
if event.Name() != "" {
fields = append(fields, &KeyValue{
Key: TagMessage,
Value: event.Name(),
})
}
event.Attributes().Range(func(key string, attr pcommon.Value) bool {
fields = append(fields, &KeyValue{Key: key, Value: getAttributeVal(attr)})
return true
@ -273,6 +268,7 @@ func spanEventsToLogs(events ptrace.SpanEventSlice) []*TraceLog {
logs = append(logs, &TraceLog{
Timestamp: float64(event.Timestamp()) / 1_000_000,
Fields: fields,
Name: event.Name(),
})
}

View File

@ -9,6 +9,7 @@ import (
"go.opentelemetry.io/collector/pdata/ptrace"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -57,7 +58,30 @@ func TestTraceToFrame(t *testing.T) {
require.Equal(t, json.RawMessage("[{\"value\":\"loki-all\",\"key\":\"service.name\"},{\"value\":\"Jaeger-Go-2.25.0\",\"key\":\"opencensus.exporterversion\"},{\"value\":\"4d019a031941\",\"key\":\"host.hostname\"},{\"value\":\"172.18.0.6\",\"key\":\"ip\"},{\"value\":\"4b19ace06df8e4de\",\"key\":\"client-uuid\"}]"), span["serviceTags"])
require.Equal(t, 1616072924072.852, span["startTime"])
require.Equal(t, 0.094, span["duration"])
require.Equal(t, "[{\"timestamp\":1616072924072.856,\"fields\":[{\"value\":\"test event\",\"key\":\"message\"},{\"value\":1,\"key\":\"chunks requested\"}]},{\"timestamp\":1616072924072.9448,\"fields\":[{\"value\":1,\"key\":\"chunks fetched\"}]}]", string(span["logs"].(json.RawMessage)))
expectedLogs := `
[
{
"timestamp": 1616072924072.856,
"name": "test event",
"fields": [
{
"value": 1,
"key": "chunks requested"
}
]
},
{
"timestamp": 1616072924072.9448,
"fields": [
{
"value": 1,
"key": "chunks fetched"
}
]
}
]
`
assert.JSONEq(t, expectedLogs, string(span["logs"].(json.RawMessage)))
})
t.Run("should transform correct traceID", func(t *testing.T) {

View File

@ -67,7 +67,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
summaryItem: css`
label: summaryItem;
display: inline;
margin-left: 0.7em;
padding-right: 0.5rem;
border-right: 1px solid ${autoColor(theme, '#ddd')};
&:last-child {
@ -90,10 +89,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
export type AccordianKeyValuesProps = {
className?: string | TNil;
data: TraceKeyValuePair[];
logName?: string;
highContrast?: boolean;
interactive?: boolean;
isOpen: boolean;
label: string;
label: string | React.ReactNode;
linksGetter?: ((pairs: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
onToggle?: null | (() => void);
};
@ -127,6 +127,7 @@ export function KeyValuesSummary({ data = null }: KeyValuesSummaryProps) {
export default function AccordianKeyValues({
className = null,
data,
logName,
highContrast = false,
interactive = true,
isOpen,
@ -134,11 +135,12 @@ export default function AccordianKeyValues({
linksGetter,
onToggle = null,
}: AccordianKeyValuesProps) {
const isEmpty = !Array.isArray(data) || !data.length;
const isEmpty = (!Array.isArray(data) || !data.length) && !logName;
const styles = useStyles2(getStyles);
const iconCls = cx(alignIcon, { [styles.emptyIcon]: isEmpty });
let arrow: React.ReactNode | null = null;
let headerProps: {} | null = null;
const tableFields = logName ? [{ key: 'event name', value: logName }, ...data] : data;
if (interactive) {
arrow = isOpen ? (
<Icon name={'angle-down'} className={iconCls} />
@ -152,6 +154,8 @@ export default function AccordianKeyValues({
};
}
const showDataSummaryFields = data.length > 0 && !isOpen;
return (
<div className={cx(className, styles.container)}>
<div
@ -165,11 +169,15 @@ export default function AccordianKeyValues({
{arrow}
<strong data-test={markers.LABEL}>
{label}
{isOpen || ':'}
{showDataSummaryFields && ':'}
</strong>
{!isOpen && <KeyValuesSummary data={data} />}
{showDataSummaryFields && (
<span className={css({ marginLeft: '0.7em' })}>
<KeyValuesSummary data={data} />
</span>
)}
</div>
{isOpen && <KeyValuesTable data={data} linksGetter={linksGetter} />}
{isOpen && <KeyValuesTable data={tableFields} linksGetter={linksGetter} />}
</div>
);
}

View File

@ -30,6 +30,7 @@ const logs = [
{ key: 'message', value: 'oh the next log message' },
{ key: 'more', value: 'stuff' },
],
name: 'foo event name',
},
];
@ -72,4 +73,47 @@ describe('AccordianLogs tests', () => {
expect(screen.getByText(/^something$/)).toBeInTheDocument();
expect(screen.getByText(/^else$/)).toBeInTheDocument();
});
it('shows log entries and long event name when expanded', () => {
const longNameLog = {
timestamp: 20,
name: 'This is a very very very very very very very long name',
fields: [{ key: 'foo', value: 'test' }],
};
setup({
isOpen: true,
logs: [longNameLog],
openedItems: new Set([longNameLog]),
} as AccordianLogsProps);
expect(
screen.getByRole('switch', {
name: '15μs (This is a very very ...)',
})
).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.queryAllByRole('cell')).toHaveLength(6);
expect(screen.getByText(/^event name$/)).toBeInTheDocument();
expect(screen.getByText(/This is a very very very very very very very long name/)).toBeInTheDocument();
});
it('renders event name and duration when events list is closed', () => {
setup({ isOpen: true, openedItems: new Set() } as AccordianLogsProps);
expect(
screen.getByRole('switch', {
name: '15μs (foo event name) : message = oh the next log message more = stuff',
})
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: '5μs: message = oh the log message something = else' })
).toBeInTheDocument();
});
it('renders event name and duration when events list is open', () => {
setup({ isOpen: true, openedItems: new Set(logs) } as AccordianLogsProps);
expect(screen.getByRole('switch', { name: '15μs (foo event name)' })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: '5μs' })).toBeInTheDocument();
});
});

View File

@ -59,6 +59,9 @@ const getStyles = (theme: GrafanaTheme2) => {
AccordianKeyValuesItem: css({
marginBottom: theme.spacing(0.5),
}),
parenthesis: css({
color: `${autoColor(theme, '#777')}`,
}),
};
};
@ -108,22 +111,35 @@ export default function AccordianLogs({
</HeaderComponent>
{isOpen && (
<div className={styles.AccordianLogsContent}>
{_sortBy(logs, 'timestamp').map((log, i) => (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? styles.AccordianKeyValuesItem : null}
data={log.fields || []}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={`${formatDuration(log.timestamp - timestamp)}`}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
))}
{_sortBy(logs, 'timestamp').map((log, i) => {
const formattedDuration = formatDuration(log.timestamp - timestamp);
const truncateLogNameInSummary = log.name && log.name.length > 20;
const formattedLogName = log.name && truncateLogNameInSummary ? log.name.slice(0, 20) + '...' : log.name;
const label = formattedLogName ? (
<span>
{formattedDuration} <span>({formattedLogName})</span>
</span>
) : (
formattedDuration
);
return (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? styles.AccordianKeyValuesItem : null}
data={log.fields || []}
logName={truncateLogNameInSummary ? log.name : undefined}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={label}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
);
})}
<small className={styles.AccordianLogsFooter}>
Log timestamps are relative to the start time of the full trace.
Event timestamps are relative to the start time of the full trace.
</small>
</div>
)}

View File

@ -48,7 +48,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
row: css`
label: row;
& > td {
padding: 0rem 0.5rem;
padding: 0.5rem 0.5rem;
height: 30px;
}
&:nth-child(2n) > td {
@ -63,6 +63,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
color: ${autoColor(theme, '#888')};
white-space: pre;
width: 125px;
vertical-align: top;
`,
copyColumn: css`
label: copyColumn;

View File

@ -31,6 +31,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

View File

@ -55,6 +55,7 @@ describe('filterSpans', () => {
],
logs: [
{
name: 'logName0',
fields: [
{
key: 'logFieldKey0',
@ -316,6 +317,10 @@ describe('filterSpans', () => {
).toEqual(new Set([spanID0]));
});
it('it should return logs have a name which matches the filter', () => {
expect(filterSpans({ ...defaultFilters, query: 'logName0' }, spans)).toEqual(new Set([spanID0]));
});
it('should return no spans when logs is null', () => {
const nullSpan = { ...span0, logs: null };
expect(

View File

@ -90,7 +90,8 @@ export function getQueryMatches(query: string, spans: TraceSpan[] | TNil) {
(span.instrumentationLibraryName && isTextInQuery(queryParts, span.instrumentationLibraryName)) ||
(span.instrumentationLibraryVersion && isTextInQuery(queryParts, span.instrumentationLibraryVersion)) ||
(span.traceState && isTextInQuery(queryParts, span.traceState)) ||
(span.logs !== null && span.logs.some((log) => isTextInKeyValues(log.fields))) ||
(span.logs !== null &&
span.logs.some((log) => (log.name && isTextInQuery(queryParts, log.name)) || isTextInKeyValues(log.fields))) ||
isTextInKeyValues(span.process.tags) ||
queryParts.some((queryPart) => queryPart === span.spanID);

View File

@ -31,6 +31,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

View File

@ -14,6 +14,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

View File

@ -132,7 +132,7 @@ function getLogs(span: collectorTypes.opentelemetryProto.trace.v1.Span) {
fields.push({ key: attribute.key, value: getAttributeValue(attribute.value) });
}
}
logs.push({ fields, timestamp: event.timeUnixNano / 1000000 });
logs.push({ fields, timestamp: event.timeUnixNano / 1000000, name: event.name });
}
}
@ -364,7 +364,7 @@ function getOTLPEvents(logs: TraceLog[]): collectorTypes.opentelemetryProto.trac
timeUnixNano: log.timestamp * 1000000,
attributes: [],
droppedAttributesCount: 0,
name: '',
name: log.name || '',
};
for (const field of log.fields) {
event.attributes!.push({

View File

@ -1920,7 +1920,7 @@ export const otlpDataFrameFromResponse = new MutableDataFrame({
name: 'logs',
type: FieldType.other,
config: {},
values: [[]],
values: [[{ name: 'DNSDone', fields: [{ key: 'addr', value: '172.18.0.6' }] }]],
},
{
name: 'references',
@ -2138,7 +2138,20 @@ export const otlpDataFrameToResponse = new MutableDataFrame({
name: 'logs',
type: FieldType.other,
config: {},
values: [[]],
values: [
[
{
fields: [
{
key: 'addr',
value: '172.18.0.6',
},
],
timestamp: 1627471657255.809,
name: 'DNSDone',
},
],
],
state: {
displayName: 'logs',
},
@ -2240,6 +2253,14 @@ export const otlpResponse = {
{ key: 'http.url', value: { stringValue: '/' } },
{ key: 'component', value: { stringValue: 'net/http' } },
],
events: [
{
name: 'DNSDone',
attributes: [{ key: 'addr', value: { stringValue: '172.18.0.6' } }],
droppedAttributesCount: 0,
timeUnixNano: 1627471657255809000,
},
],
links: [
{
spanId: 'spanId',