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)),

Reply via email to