Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,43 @@
/* 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 .event-attributes {
display: flex;
flex-wrap: wrap;
gap: 4px;
}

.TraceLogsView .event-attributes .attribute-tag {
background: var(--surface-tertiary, #f5f5f5);
border-radius: 3px;
padding: 1px 6px;
font-family: monospace;
font-size: 12px;
white-space: nowrap;
}

.TraceLogsView .span-id-cell {
font-family: monospace;
font-size: 12px;
}

.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,188 @@
// 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
expect(screen.getByText('request_started')).toBeInTheDocument();
expect(screen.getByText('request_finished')).toBeInTheDocument();
expect(screen.getByText('db_query')).toBeInTheDocument();
});

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();
});

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();
});

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 event attributes as tags', () => {
const trace = transformTraceData(baseTrace).asOtelTrace();
render(<TraceLogsView trace={trace} useOtelTerms={false} />);

// db_query event has attribute query=SELECT *
expect(screen.getByText('query=SELECT *')).toBeInTheDocument();
});

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('Attributes')).toBeInTheDocument();
expect(screen.getByText('Span ID')).toBeInTheDocument();
});
});
155 changes: 155 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,155 @@
// Copyright (c) 2025 The Jaeger Authors.
Comment thread
SoumyaRaikwar marked this conversation as resolved.
Outdated
// SPDX-License-Identifier: Apache-2.0

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

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

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 columns: ColumnProps<TraceLogEntry>[] = [
{
title: 'Timestamp',
dataIndex: 'relativeTime',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.relativeTime - b.relativeTime,
defaultSortOrder: 'ascend' as const,
render: (relativeTime: Microseconds) => {
return (
<Tooltip title={formatDuration(relativeTime)}>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatDuration(relativeTime)}</span>
</Tooltip>
);
},
width: '12%',
},
{
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: '15%',
},
{
title: useOtelTerms ? 'Span Name' : 'Operation',
dataIndex: 'spanName',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.spanName.localeCompare(b.spanName),
width: '15%',
},
{
title: useOtelTerms ? 'Event' : 'Log',
dataIndex: 'eventName',
sorter: (a: TraceLogEntry, b: TraceLogEntry) => a.eventName.localeCompare(b.eventName),
width: '12%',
},
{
title: 'Attributes',
dataIndex: 'attributes',
render: (attributes: IAttribute[]) => {
if (!attributes || attributes.length === 0) {
return <span>—</span>;
}
return (
<div className="event-attributes">
{attributes.map((attr: IAttribute) => (
<span key={attr.key} className="attribute-tag">
{attr.key}={String(attr.value)}
</span>
))}
</div>
);
},
width: '31%',
},
{
title: 'Span ID',
dataIndex: 'spanID',
render: (spanID: string, record: TraceLogEntry) => (
<a
href={prefixUrl(`/trace/${record.traceID}?uiFind=${spanID}`)}
target={getTargetEmptyOrBlank()}
rel="noopener noreferrer"
className="span-id-cell"
>
{spanID}
</a>
),
width: '15%',
},
];

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
Loading