Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* Copyright (c) 2025 The Jaeger Authors. */
/* SPDX-License-Identifier: Apache-2.0 */

.TraceLogsView {
padding: 8px;
}

.TraceLogsView--title {
margin: 0 0 8px 0;
font-size: 1.1em;
font-weight: 600;
}

.TraceLogsView .ant-table {
font-size: 13px;
}

.TraceLogsView--mono {
font-family: monospace;
font-size: 12px;
}

.TraceLogsView--no-attrs {
color: var(--text-secondary, #999);
}

.TraceLogsView--empty {
text-align: center;
padding: 48px 16px;
color: var(--text-secondary, #999);
font-size: 14px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2025 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TraceLogsView from './index';
import transformTraceData from '../../../model/transform-trace-data';

// Test trace with logs/events on some spans
const baseTrace = {
traceID: 'trace-abc',
spans: [
{
traceID: 'trace-abc',
spanID: 'span-1',
operationName: 'op1',
startTime: 1000000,
duration: 500000,
references: [],
tags: [],
logs: [
{
timestamp: 1100000,
fields: [
{ key: 'event', value: 'request_started' },
{ key: 'level', value: 'INFO' },
],
},
{
timestamp: 1400000,
fields: [
{ key: 'event', value: 'request_finished' },
{ key: 'status_code', value: '200' },
],
},
],
processID: 'p1',
warnings: null,
},
{
traceID: 'trace-abc',
spanID: 'span-2',
operationName: 'op2',
startTime: 1200000,
duration: 100000,
references: [{ refType: 'CHILD_OF', traceID: 'trace-abc', spanID: 'span-1' }],
tags: [],
logs: [
{
timestamp: 1250000,
fields: [
{ key: 'event', value: 'db_query' },
{ key: 'query', value: 'SELECT *' },
],
},
],
processID: 'p2',
warnings: null,
},
{
traceID: 'trace-abc',
spanID: 'span-3',
operationName: 'op3',
startTime: 1300000,
duration: 50000,
references: [{ refType: 'CHILD_OF', traceID: 'trace-abc', spanID: 'span-1' }],
tags: [],
logs: [],
processID: 'p1',
warnings: null,
},
],
processes: {
p1: { serviceName: 'frontend', tags: [] },
p2: { serviceName: 'backend', tags: [] },
},
};

const noLogsTrace = {
traceID: 'trace-nologs',
spans: [
{
traceID: 'trace-nologs',
spanID: 'span-x',
operationName: 'opx',
startTime: 2000000,
duration: 100000,
references: [],
tags: [],
logs: [],
processID: 'p1',
warnings: null,
},
],
processes: {
p1: { serviceName: 'service-a', tags: [] },
},
};

describe('<TraceLogsView>', () => {
it('renders trace logs table with all log entries', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

expect(screen.getByTestId('trace-logs-view')).toBeInTheDocument();
expect(screen.getByText('Trace Logs')).toBeInTheDocument();

// Should show all 3 log entries from 2 spans (event names appear in both Log column and Attributes summary)
expect(screen.getAllByText('request_started').length).toBeGreaterThan(0);
expect(screen.getAllByText('request_finished').length).toBeGreaterThan(0);
expect(screen.getAllByText('db_query').length).toBeGreaterThan(0);
});

it('shows empty message when trace has no logs', () => {
const trace = transformTraceData(noLogsTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

expect(screen.getByTestId('trace-logs-empty')).toBeInTheDocument();
expect(screen.getByText(/No logs found in this trace/)).toBeInTheDocument();
});

it('uses OTel terminology when useOtelTerms is true', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms />);

expect(screen.getByText('Trace Events')).toBeInTheDocument();
expect(screen.getByText('Event')).toBeInTheDocument();
expect(screen.getByText('Span Name')).toBeInTheDocument();
// OTel uses 'Attributes'
expect(screen.getAllByText('Attributes').length).toBeGreaterThan(0);
});

it('uses legacy terminology when useOtelTerms is false', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

expect(screen.getByText('Trace Logs')).toBeInTheDocument();
expect(screen.getByText('Log')).toBeInTheDocument();
expect(screen.getByText('Operation')).toBeInTheDocument();
// Legacy uses 'Tags'
expect(screen.getAllByText('Tags').length).toBeGreaterThan(0);
});

it('shows empty OTel message when useOtelTerms is true and no events', () => {
const trace = transformTraceData(noLogsTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms />);

expect(screen.getByText('Trace Events')).toBeInTheDocument();
expect(screen.getByText(/No events found in this trace/)).toBeInTheDocument();
});

it('displays service name and span name from parent span', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

// frontend service
expect(screen.getAllByText('frontend').length).toBeGreaterThan(0);
// backend service
expect(screen.getAllByText('backend').length).toBeGreaterThan(0);
});

it('renders attributes using AccordionAttributes (collapsed summary)', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

// AccordionAttributes renders a collapsed summary showing key=value pairs
// The attribute keys from the log entries should appear in the summary
expect(screen.getAllByText('level').length).toBeGreaterThan(0);
});

it('renders span IDs as links', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

const spanLinks = screen.getAllByText('span-1');
// span-1 has 2 logs so appears twice
expect(spanLinks.length).toBe(2);
expect(spanLinks[0].closest('a')).toHaveAttribute('href', expect.stringContaining('uiFind=span-1'));
});

it('renders the table with correct columns', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

expect(screen.getByText('Timestamp')).toBeInTheDocument();
expect(screen.getByText('Service Name')).toBeInTheDocument();
Comment thread
SoumyaRaikwar marked this conversation as resolved.
Outdated
expect(screen.getByText('Operation')).toBeInTheDocument();
expect(screen.getByText('Log')).toBeInTheDocument();
expect(screen.getByText('Span ID')).toBeInTheDocument();
});
});
166 changes: 166 additions & 0 deletions packages/jaeger-ui/src/components/TracePage/TraceLogsView/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2025 The Jaeger Authors.
Comment thread
SoumyaRaikwar marked this conversation as resolved.
Outdated
// SPDX-License-Identifier: Apache-2.0

import React, { useMemo, useState } from 'react';
import { Table } from 'antd';
import { ColumnProps } from 'antd/es/table';
import _sortBy from 'lodash/sortBy';
import './index.css';

import AccordionAttributes from '../TraceTimelineViewer/SpanDetail/AccordionAttributes';
import { IOtelTrace, IOtelSpan, IEvent, IAttribute } from '../../../types/otel';
import { Microseconds } from '../../../types/units';
import { formatDuration } from '../../../utils/date';
import prefixUrl from '../../../utils/prefix-url';
import { getTargetEmptyOrBlank } from '../../../utils/config/get-target';

type TraceLogEntry = {
key: string;
timestamp: Microseconds;
relativeTime: Microseconds;
eventName: string;
attributes: IAttribute[];
spanID: string;
spanName: string;
serviceName: string;
traceID: string;
};

type Props = {
trace: IOtelTrace;
useOtelTerms: boolean;
};

function collectLogEntries(trace: IOtelTrace): TraceLogEntry[] {
const entries: TraceLogEntry[] = [];
trace.spans.forEach((span: IOtelSpan) => {
if (span.events && span.events.length > 0) {
span.events.forEach((event: IEvent, index: number) => {
entries.push({
key: `${span.spanID}-${index}`,
timestamp: event.timestamp,
relativeTime: (event.timestamp - trace.startTime) as Microseconds,
eventName: event.name,
attributes: event.attributes,
spanID: span.spanID,
spanName: span.name,
serviceName: span.resource.serviceName,
traceID: span.traceID,
});
});
}
});
return _sortBy(entries, 'timestamp');
}

export default function TraceLogsView({ trace, useOtelTerms }: Props) {
const logEntries = useMemo(() => collectLogEntries(trace), [trace]);
const [openAttributes, setOpenAttributes] = useState<Set<string>>(new Set());

const toggleAttributes = (key: string) => {
setOpenAttributes(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};

if (logEntries.length === 0) {
return (
<div className="TraceLogsView" data-testid="trace-logs-view">
<h3 className="TraceLogsView--title">{useOtelTerms ? 'Trace Events' : 'Trace Logs'}</h3>
<div className="TraceLogsView--empty" data-testid="trace-logs-empty">
No {useOtelTerms ? 'events' : 'logs'} found in this trace.
</div>
</div>
);
}

const attributesLabel = useOtelTerms ? 'Attributes' : 'Tags';

const columns: ColumnProps<TraceLogEntry>[] = [
{
title: 'Timestamp',
dataIndex: 'relativeTime',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.relativeTime - b.relativeTime,
defaultSortOrder: 'ascend' as const,
render: (relativeTime: Microseconds) => (
<span className="TraceLogsView--mono">{formatDuration(relativeTime)}</span>
),
width: 90,
},
{
title: 'Service Name',
Comment thread
yurishkuro marked this conversation as resolved.
Outdated
dataIndex: 'serviceName',
Comment thread
SoumyaRaikwar marked this conversation as resolved.
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.serviceName.localeCompare(b.serviceName),
width: 120,
},
{
title: useOtelTerms ? 'Span Name' : 'Operation',
dataIndex: 'spanName',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.spanName.localeCompare(b.spanName),
width: 140,
},
{
title: useOtelTerms ? 'Event' : 'Log',
dataIndex: 'eventName',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.eventName.localeCompare(b.eventName),
width: 120,
},
{
title: attributesLabel,
dataIndex: 'attributes',
render: (attributes: IAttribute[], record: TraceLogEntry) => {
if (!attributes || attributes.length === 0) {
return <span className="TraceLogsView--no-attrs">—</span>;
}
return (
<AccordionAttributes
data={attributes}
label={attributesLabel}
linksGetter={null}
isOpen={openAttributes.has(record.key)}
onToggle={() => toggleAttributes(record.key)}
/>
);
},
},
{
title: 'Span ID',
dataIndex: 'spanID',
render: (spanID: string, record: TraceLogEntry) => (
<a
href={prefixUrl(`/trace/${record.traceID}?uiFind=${spanID}`)}
target={getTargetEmptyOrBlank()}
rel="noopener noreferrer"
className="TraceLogsView--mono"
>
{spanID}
</a>
),
width: 130,
},
];

return (
<div className="TraceLogsView" data-testid="trace-logs-view">
<h3 className="TraceLogsView--title">{useOtelTerms ? 'Trace Events' : 'Trace Logs'}</h3>
<Table
className="trace-logs-table"
columns={columns}
dataSource={logEntries}
rowKey="key"
pagination={{
total: logEntries.length,
pageSizeOptions: ['10', '20', '50', '100'],
showSizeChanger: true,
showQuickJumper: true,
}}
/>
</div>
);
}
Loading