This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4846c0f77 fix(trace): avoid false lifecycle latency in key query
results (#6635)
4846c0f77 is described below
commit 4846c0f7733efe4e457c22f7ca9642c581b56a82
Author: coder999o <[email protected]>
AuthorDate: Thu Oct 15 18:16:27 2026 +0800
fix(trace): avoid false lifecycle latency in key query results (#6635)
---
web/src/api/message.test.ts | 14 ++-
web/src/api/message.ts | 4 +
web/src/i18n/translations.ts | 4 +
.../pages/instance/__tests__/MessagePage.test.tsx | 103 +++++++++++++++++++++
web/src/pages/instance/message.tsx | 7 ++
web/src/utils/messageTraceDiagnostics.test.ts | 61 ++++++++++++
web/src/utils/messageTraceDiagnostics.ts | 25 +++--
7 files changed, 206 insertions(+), 12 deletions(-)
diff --git a/web/src/api/message.test.ts b/web/src/api/message.test.ts
index f3dc9bcec..cc851cca5 100644
--- a/web/src/api/message.test.ts
+++ b/web/src/api/message.test.ts
@@ -128,7 +128,10 @@ describe('message API', () => {
.onGet('/messages/msg-1/trace', { params: { instanceId: 'instance-1',
topic: 'orders' } })
.reply(200, { code: 200, data: trace });
- await expect(getMessageTrace('msg-1', 'instance-1',
'orders')).resolves.toEqual(trace);
+ await expect(getMessageTrace('msg-1', 'instance-1',
'orders')).resolves.toEqual({
+ ...trace,
+ queryType: 'MSG_ID',
+ });
});
it('maps backend trace node statuses to Ant Design step statuses', async ()
=> {
@@ -206,6 +209,7 @@ describe('message API', () => {
.reply(200, { code: 200, data: trace });
const mapped = await getMessageTraceByKey('order-1', 'instance-1');
+ expect(mapped?.queryType).toBe('KEY');
expect(mapped?.nodes.map((node) => node.status)).toEqual(['error',
'finish', 'wait']);
});
@@ -223,6 +227,7 @@ describe('message API', () => {
await expect(getMessageTraceByKey('no-nodes',
'instance-1')).resolves.toEqual({
consumerStatus: [],
nodes: [],
+ queryType: 'KEY',
});
});
@@ -237,9 +242,10 @@ describe('message API', () => {
})
.reply(200, { code: 200, data: trace });
- await expect(getMessageTrace('AC1E0A64/0000 2A9F:1', 'instance-1',
'orders')).resolves.toEqual(
- trace,
- );
+ await expect(getMessageTrace('AC1E0A64/0000 2A9F:1', 'instance-1',
'orders')).resolves.toEqual({
+ ...trace,
+ queryType: 'MSG_ID',
+ });
});
it('posts direct consumption to the message API', async () => {
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index d146dde26..b1d7dd2b7 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -41,6 +41,8 @@ export interface ConsumerStatus {
export interface TraceRecord {
nodes: TraceNode[];
consumerStatus: ConsumerStatus[];
+ /** Lookup context attached by the web API adapter, rather than the server
payload. */
+ queryType?: 'MSG_ID' | 'KEY';
}
export interface MessageQuery {
@@ -185,6 +187,7 @@ export async function getMessageTrace(
const trace = res.data.data;
return {
...trace,
+ queryType: 'MSG_ID' as const,
nodes: (trace.nodes ?? []).map((node) => ({
...node,
status: mapTraceNodeStatus(node.status),
@@ -217,6 +220,7 @@ export async function getMessageTraceByKey(
}
return {
...trace,
+ queryType: 'KEY',
nodes: (trace.nodes ?? []).map((node) => ({
...node,
status: mapTraceNodeStatus(node.status),
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 7e743b4ec..5407a970c 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -601,6 +601,10 @@ const translations: Record<string, Record<Lang, string>> =
{
'messagePage.deliveryStatusPending': { zh: '等待中', en: 'Pending' },
'messagePage.traceStatusCritical': { zh: '异常', en: 'Critical' },
'messagePage.traceDiagnostics': { zh: '轨迹诊断', en: 'Trace Diagnostics' },
+ 'messagePage.keyTraceLatencyUnavailable': {
+ zh: 'Key 查询可能包含多条消息,不计算端到端和阶段间耗时;仍显示节点耗时与消费状态。',
+ en: 'A key lookup may include multiple messages. End-to-end and
inter-stage latency are unavailable; node costs and delivery states remain
visible.',
+ },
'messagePage.healthScore': { zh: '健康分', en: 'Health Score' },
'messagePage.traceStageCount': { zh: '轨迹阶段', en: 'Trace Stages' },
'messagePage.endToEndLatency': { zh: '端到端耗时', en: 'End-to-End Latency' },
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index d7e45c738..ae6452114 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -578,6 +578,109 @@ describe('Message page query history', () => {
expect(screen.queryAllByRole('alert')).toHaveLength(0);
});
+ it.each(['zh', 'en'])(
+ 'keeps key results independent from the editable lookup mode in %s',
+ async (lang) => {
+ localStorage.setItem('rocketmq-studio-language', lang);
+ const en = lang === 'en';
+ const info = en
+ ? 'A key lookup may include multiple messages. End-to-end and
inter-stage latency are unavailable; node costs and delivery states remain
visible.'
+ : 'Key 查询可能包含多条消息,不计算端到端和阶段间耗时;仍显示节点耗时与消费状态。';
+ const nodes = [
+ {
+ title: 'Produce A',
+ timestamp: 1785456000000,
+ costTime: 1,
+ status: 'finish',
+ description: 'A',
+ },
+ {
+ title: 'Consume A',
+ timestamp: 1785456000010,
+ costTime: 9,
+ status: 'finish',
+ description: 'A',
+ },
+ {
+ title: 'Produce B',
+ timestamp: 1785456060000,
+ costTime: 1,
+ status: 'finish',
+ description: 'B',
+ },
+ {
+ title: 'Consume B',
+ timestamp: 1785456060010,
+ costTime: 9,
+ status: 'finish',
+ description: 'B',
+ },
+ ];
+ const consumerStatus = [
+ {
+ group: 'cg-orders',
+ deliveryStatus: 'success',
+ consumeTime: 1785456060010,
+ retryCount: 0,
+ },
+ ];
+
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-CORRELATION')]);
+ messageServiceMocks.getMessageTrace.mockResolvedValue({
+ nodes: nodes.slice(0, 2),
+ consumerStatus,
+ });
+ messageServiceMocks.getMessageTraceByKey.mockResolvedValue({
+ queryType: 'KEY',
+ nodes,
+ consumerStatus,
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText(en ? 'Query by Message ID' : '按
Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await
screen.findAllByText('order-create')));
+ await user.type(
+ screen.getByPlaceholderText(en ? 'Enter message ID' : '输入 Message ID'),
+ 'MID-CORRELATION',
+ );
+ await user.click(screen.getByRole('button', { name: en ? /^searchQuery$/
: /^search查询$/ }));
+ const row = await screen.findByRole('row', { name: /MID-CORRELATION/ });
+ await user.click(within(row).getByRole('button', { name: en ? /Trace/ :
/轨迹/ }));
+ const dialog = await screen.findByRole('dialog');
+ await within(dialog).findByText('Produce A');
+ await user.click(within(dialog).getByText(en ? 'Query by Message Key' :
'按 Message Key'));
+ const keyInput = within(dialog).getByPlaceholderText(
+ en ? 'Enter message key' : '输入 Message Key',
+ );
+ await user.clear(keyInput);
+ await user.type(keyInput, 'order-key');
+ await user.click(
+ within(dialog).getByRole('button', { name: en ? /Query Trace/ : /查询轨迹/
}),
+ );
+
+ expect(await within(dialog).findByText(info)).toBeInTheDocument();
+ expect(within(dialog).getByText(en ? 'Trace Healthy' :
'轨迹健康')).toBeInTheDocument();
+ const latency = within(dialog)
+ .getByText(en ? 'End-to-End Latency' : '端到端耗时')
+ .closest('.ant-statistic');
+
expect(latency?.querySelector('.ant-statistic-content')).toHaveTextContent('-');
+ await user.click(within(dialog).getByText(en ? 'Query by Message ID' :
'按 Message ID'));
+ expect(within(dialog).getByText(info)).toBeInTheDocument();
+
expect(latency?.querySelector('.ant-statistic-content')).toHaveTextContent('-');
+ await user.click(
+ within(dialog).getByRole('button', { name: en ? /Query Trace/ : /查询轨迹/
}),
+ );
+ await waitFor(() =>
expect(within(dialog).queryByText(info)).not.toBeInTheDocument());
+ await waitFor(() => {
+ const currentLatency = within(dialog)
+ .getByText(en ? 'End-to-End Latency' : '端到端耗时')
+ .closest('.ant-statistic');
+
expect(currentLatency?.querySelector('.ant-statistic-content')).toHaveTextContent('10
ms');
+ });
+ },
+ );
+
it('renders trace diagnostics in English when the UI language is English',
async () => {
localStorage.setItem('rocketmq-studio-language', 'en');
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-TRACE-EN')]);
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 0ac80ccd2..ccab0a707 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -52,6 +52,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
import MessageQueryHistoryModal from
'../../components/MessageQueryHistoryModal';
import MessageProperties from '../../components/MessageProperties';
@@ -1137,6 +1138,12 @@ const MessagePageContent = ({
<Alert showIcon type="warning" message={traceError} />
) : traceData?.nodes?.length ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
+ {traceData.queryType === 'KEY' && (
+ <InfoBanner
+ description={t('messagePage.keyTraceLatencyUnavailable')}
+ style={{ marginBottom: 0 }}
+ />
+ )}
<TraceDiagnosticsPanel diagnostics={traceDiagnostics} />
<Steps
direction="vertical"
diff --git a/web/src/utils/messageTraceDiagnostics.test.ts
b/web/src/utils/messageTraceDiagnostics.test.ts
index eabfbd1d9..e9bf7b992 100644
--- a/web/src/utils/messageTraceDiagnostics.test.ts
+++ b/web/src/utils/messageTraceDiagnostics.test.ts
@@ -53,6 +53,67 @@ const issueCodes = (trace: TraceRecord) =>
analyzeMessageTrace(trace).issues.map((issue) => issue.code);
describe('message trace diagnostics', () => {
+ it.each(['chronological', 'interleaved'])(
+ 'does not join separate message lifecycles in %s key results',
+ (order) => {
+ const nodes = [
+ node('Produce A', '2026-07-01T10:00:00.000Z', 1),
+ node('Consume A', '2026-07-01T10:00:00.010Z', 9),
+ node('Produce B', '2026-07-01T10:01:00.000Z', 1),
+ node('Consume B', '2026-07-01T10:01:00.010Z', 9),
+ ];
+ const diagnostics = analyzeMessageTrace(
+ baseTrace({
+ queryType: 'KEY',
+ nodes: order === 'interleaved' ? [nodes[0], nodes[2], nodes[1],
nodes[3]] : nodes,
+ consumerStatus: [delivery('cg-orders', 'success',
'2026-07-01T10:01:00.010Z')],
+ }),
+ );
+
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.issues).toEqual([]);
+ expect(diagnostics.summary).toMatchObject({
+ endToEndLatencyMs: null,
+ slowestGap: null,
+ totalNodeCostMs: 20,
+ successfulConsumerRate: 100,
+ });
+ expect(
+ diagnostics.phases.every(
+ (phase) => phase.latencyFromPreviousMs === null &&
phase.latencyFromStartMs === null,
+ ),
+ ).toBe(true);
+ expect(diagnostics.deliveries[0].latencyFromTraceStartMs).toBeNull();
+ },
+ );
+
+ it('keeps actual node cost and delivery failures in key diagnostics', () => {
+ const diagnostics = analyzeMessageTrace(
+ baseTrace({
+ queryType: 'KEY',
+ nodes: [
+ node('Produce A', '2026-07-01T10:00:00.000Z', 1),
+ node('Consume B', '2026-07-01T10:01:00.010Z', 9000, 'error'),
+ ],
+ consumerStatus: [delivery('cg-orders', 'failed',
'2026-07-01T10:01:00.010Z', 2)],
+ }),
+ );
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.endToEndLatencyMs).toBeNull();
+ expect(diagnostics.summary.slowestNode?.valueMs).toBe(9000);
+ expect(diagnostics.issues.map((issue) => issue.code)).toEqual(
+ expect.arrayContaining([
+ 'FAILED_TRACE_NODE',
+ 'SLOW_TRACE_NODE',
+ 'FAILED_CONSUMER_DELIVERY',
+ 'RETRIED_CONSUMER_DELIVERY',
+ ]),
+ );
+ expect(diagnostics.issues.map((issue) =>
issue.code)).not.toContain('SLOW_END_TO_END_TRACE');
+ expect(diagnostics.issues.map((issue) =>
issue.code)).not.toContain('SLOW_TRACE_GAP');
+ });
+
it('summarizes a complete trace as healthy', () => {
const diagnostics = analyzeMessageTrace(baseTrace());
diff --git a/web/src/utils/messageTraceDiagnostics.ts
b/web/src/utils/messageTraceDiagnostics.ts
index e5b0818f2..3df9a5bdf 100644
--- a/web/src/utils/messageTraceDiagnostics.ts
+++ b/web/src/utils/messageTraceDiagnostics.ts
@@ -299,7 +299,7 @@ const collectPhaseIssues = (
return issues;
};
-const mapPhases = (nodes: TraceNode[]): TraceDiagnosticPhase[] => {
+const mapPhases = (nodes: TraceNode[], singleLifecycle: boolean):
TraceDiagnosticPhase[] => {
const firstTimestamp = nodes
.map((node) => parseTimestamp(node.timestamp))
.find((ts) => ts != null);
@@ -308,9 +308,13 @@ const mapPhases = (nodes: TraceNode[]):
TraceDiagnosticPhase[] => {
return nodes.map((node, index) => {
const timestampMs = parseTimestamp(node.timestamp);
const latencyFromPreviousMs =
- timestampMs != null && previousTimestamp != null ? timestampMs -
previousTimestamp : null;
+ singleLifecycle && timestampMs != null && previousTimestamp != null
+ ? timestampMs - previousTimestamp
+ : null;
const latencyFromStartMs =
- timestampMs != null && firstTimestamp != null ? timestampMs -
firstTimestamp : null;
+ singleLifecycle && timestampMs != null && firstTimestamp != null
+ ? timestampMs - firstTimestamp
+ : null;
if (timestampMs != null) previousTimestamp = timestampMs;
return {
@@ -465,13 +469,14 @@ const maxByValue = <T>(items: T[], extractor: (item: T)
=> number | null): T | n
const buildSummary = (
phases: TraceDiagnosticPhase[],
deliveries: ConsumerDeliveryDiagnostic[],
+ singleLifecycle: boolean,
): TraceDiagnosticSummary => {
const totalNodeCostMs = phases.reduce((sum, phase) => sum +
(phase.costTimeMs ?? 0), 0);
const validTimestamps = phases
.map((phase) => phase.timestampMs)
.filter((value): value is number => value != null);
const endToEndLatencyMs =
- validTimestamps.length >= 2
+ singleLifecycle && validTimestamps.length >= 2
? Math.max(...validTimestamps) - Math.min(...validTimestamps)
: null;
const successCount = deliveries.filter(
@@ -571,11 +576,15 @@ export function analyzeMessageTrace(
options: TraceDiagnosticOptions = {},
): MessageTraceDiagnostics {
const normalizedOptions = { ...DEFAULT_OPTIONS, ...options };
- const phases = mapPhases(trace?.nodes ?? []);
- const traceStartMs =
- phases.map((phase) => phase.timestampMs).find((value) => value != null) ??
null;
+ // Key results can contain several messages without per-node message IDs.
+ // Their combined timestamps cannot establish a single message lifecycle.
+ const singleLifecycle = trace?.queryType !== 'KEY';
+ const phases = mapPhases(trace?.nodes ?? [], singleLifecycle);
+ const traceStartMs = singleLifecycle
+ ? (phases.map((phase) => phase.timestampMs).find((value) => value != null)
?? null)
+ : null;
const deliveries = mapDeliveries(trace?.consumerStatus ?? [], traceStartMs);
- const summary = buildSummary(phases, deliveries);
+ const summary = buildSummary(phases, deliveries, singleLifecycle);
const issues = [
...collectSummaryIssues(summary, normalizedOptions),
...phases.flatMap((phase) => collectPhaseIssues(phase, normalizedOptions)),