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 f7b6338cc feat(trace): persist custom trace topics in query history
(#2644)
f7b6338cc is described below
commit f7b6338ccbe6715a9de62917682a55ee6d5649d7
Author: xdz997 <[email protected]>
AuthorDate: Fri Sep 4 15:52:13 2026 +0800
feat(trace): persist custom trace topics in query history (#2644)
---
.../studio/instance/message/MessageService.java | 31 +++--
.../message/QueryHistorySchemaMigration.java | 5 +-
.../instance/message/QueryHistoryService.java | 21 +++-
.../instance/message/TraceQueryHistoryVO.java | 1 +
.../studio/persistence/entity/RmqTraceQuery.java | 7 ++
server/src/main/resources/db/schema.sql | 5 +-
.../instance/message/MessageServiceTest.java | 38 ++++++
.../message/QueryHistorySchemaMigrationTest.java | 82 +++++++++++++
.../QueryHistoryServiceIntegrationTest.java | 8 +-
.../instance/message/QueryHistoryServiceTest.java | 46 ++++++++
web/src/api/messageHistory.ts | 2 +
web/src/components/MessageQueryHistoryDrawer.tsx | 8 +-
.../__tests__/MessageQueryHistoryDrawer.test.tsx | 2 +
.../__tests__/MessagePageAsyncState.test.tsx | 127 +++++++++++++++++++++
web/src/pages/instance/message.tsx | 36 ++++--
web/src/utils/messageTraceTopicStorage.test.ts | 58 ++++++++++
web/src/utils/messageTraceTopicStorage.ts | 77 +++++++++++++
17 files changed, 525 insertions(+), 29 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index 544f72681..6b391c943 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -88,7 +88,7 @@ public class MessageService {
TraceRecordVO result = providerRegistry.byInstanceId(instanceId)
.map(provider -> provider.getMessageTrace(instanceId, msgId,
topic))
.orElseGet(() -> messageProvider.getMessageTrace(instanceId,
msgId, topic));
- recordTraceQuery(instanceId, msgId, topic, result);
+ recordTraceQuery(instanceId, msgId, topic, null, result);
return result;
}
@@ -131,16 +131,18 @@ public class MessageService {
if (!StringUtils.hasText(msgId)) {
throw new BusinessException(400, "msgId is required");
}
- if (!StringUtils.hasText(traceTopic)) {
+ String normalizedTraceTopic = normalizeOptional(traceTopic);
+ if (normalizedTraceTopic == null) {
// No custom trace topic: fall back to the legacy 3-arg path so
providers that
// only implement message-id tracing (Aliyun/Tencent) keep working
unchanged.
return getMessageTrace(instanceId, msgId, topic);
}
- log.info("Getting message trace: msgId={}, topic={}, traceTopic={}",
msgId, topic, traceTopic);
+ log.info("Getting message trace: msgId={}, topic={}, traceTopic={}",
msgId, topic,
+ normalizedTraceTopic);
TraceRecordVO result = providerRegistry.byInstanceId(instanceId)
- .map(provider -> provider.getMessageTrace(instanceId, msgId,
topic, traceTopic))
- .orElseGet(() -> messageProvider.getMessageTrace(instanceId,
msgId, topic, traceTopic));
- recordTraceQuery(instanceId, msgId, topic, result);
+ .map(provider -> provider.getMessageTrace(instanceId, msgId,
topic, normalizedTraceTopic))
+ .orElseGet(() -> messageProvider.getMessageTrace(instanceId,
msgId, topic, normalizedTraceTopic));
+ recordTraceQuery(instanceId, msgId, topic, normalizedTraceTopic,
result);
return result;
}
@@ -148,12 +150,14 @@ public class MessageService {
if (!StringUtils.hasText(key)) {
throw new BusinessException(400, "key is required");
}
- log.info("Getting message trace by key: key={}, topic={},
traceTopic={}", key, topic, traceTopic);
+ String normalizedTraceTopic = normalizeOptional(traceTopic);
+ log.info("Getting message trace by key: key={}, topic={},
traceTopic={}", key, topic,
+ normalizedTraceTopic);
// Trace query history is keyed by message id; key-based lookups are
intentionally
// not recorded so the key is not misreported as a message id.
return providerRegistry.byInstanceId(instanceId)
- .map(provider -> provider.getMessageTraceByKey(instanceId,
key, topic, traceTopic))
- .orElseGet(() ->
messageProvider.getMessageTraceByKey(instanceId, key, topic, traceTopic));
+ .map(provider -> provider.getMessageTraceByKey(instanceId,
key, topic, normalizedTraceTopic))
+ .orElseGet(() ->
messageProvider.getMessageTraceByKey(instanceId, key, topic,
normalizedTraceTopic));
}
private void recordMessageQuery(String instanceId, String topic, String
msgId, String tag,
String key, Long startTime, Long endTime,
List<MessageRecordVO> result) {
@@ -167,17 +171,22 @@ public class MessageService {
}
}
- private void recordTraceQuery(String instanceId, String msgId, String
topic, TraceRecordVO result) {
+ private void recordTraceQuery(String instanceId, String msgId, String
topic, String traceTopic,
+ TraceRecordVO result) {
int nodeCount = result == null || result.getNodes() == null ? 0 :
result.getNodes().size();
int consumerCount = result == null || result.getConsumerStatus() ==
null ? 0
: result.getConsumerStatus().size();
try {
- queryHistoryService.recordTraceQuery(instanceId, msgId, topic,
nodeCount, consumerCount);
+ queryHistoryService.recordTraceQuery(instanceId, msgId, topic,
traceTopic, nodeCount, consumerCount);
} catch (RuntimeException failure) {
log.warn("Failed to record trace query history: {}",
failure.getMessage());
}
}
+ private static String normalizeOptional(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
+
private void validateTopicQueryWindow(String topic, String msgId, String
key, Long startTime, Long endTime) {
boolean hasTopic = StringUtils.hasText(topic);
boolean hasMessageId = StringUtils.hasText(msgId);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigration.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigration.java
index fd13062b9..292745660 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigration.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigration.java
@@ -20,13 +20,14 @@ import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
-/** Adds query-history indexes to Studio databases created before the index
contract was added. */
+/** Adds query-history columns and indexes to Studio databases created before
the current schema. */
@Slf4j
@Component
@RequiredArgsConstructor
public class QueryHistorySchemaMigration implements ApplicationRunner {
private static final List<Column> COLUMNS = List.of(
- new Column("rmq_instance_message", "result_snapshot",
"MEDIUMTEXT"));
+ new Column("rmq_instance_message", "result_snapshot",
"MEDIUMTEXT"),
+ new Column("rmq_instance_trace", "trace_topic", "VARCHAR(255)"));
private static final List<Index> INDEXES = List.of(
new Index("rmq_instance_message", "idx_message_query_owner_lookup",
"queried_by, cluster_id, gmt_create, id"),
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
index b028084dc..759888de4 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/QueryHistoryService.java
@@ -149,10 +149,16 @@ public class QueryHistoryService {
}
}
- public void recordTraceQuery(String clusterId, String msgId, String topic,
int nodeCount, int consumerCount) {
+ /**
+ * Records a message-id trace lookup together with the exact trace topic
used by the
+ * provider. The topic is optional because older/default lookups use the
provider default.
+ */
+ public void recordTraceQuery(String clusterId, String msgId, String topic,
String traceTopic,
+ int nodeCount, int consumerCount) {
RmqTraceQuery query = new RmqTraceQuery();
query.setMsgId(msgId);
query.setTopic(topic);
+ query.setTraceTopic(normalizeOptional(traceTopic));
query.setNodeCount(nodeCount);
query.setConsumerCount(consumerCount);
query.setClusterId(clusterId);
@@ -161,7 +167,12 @@ public class QueryHistoryService {
query.setGmtCreate(now);
query.setGmtModified(now);
traceQueryMapper.insert(query);
- log.debug("Trace query recorded: clusterId={} msgId={} topic={}",
clusterId, msgId, topic);
+ log.debug("Trace query recorded: clusterId={} msgId={} topic={}
traceTopic={}", clusterId, msgId,
+ topic, query.getTraceTopic());
+ }
+
+ public void recordTraceQuery(String clusterId, String msgId, String topic,
int nodeCount, int consumerCount) {
+ recordTraceQuery(clusterId, msgId, topic, null, nodeCount,
consumerCount);
}
public PageResult<MessageQueryHistoryVO> listMessageQueries(String
clusterId, String queryType,
@@ -194,6 +205,7 @@ public class QueryHistoryService {
.and(StringUtils.hasText(search), nested -> nested
.like("topic", pattern)
.or().like("msg_id", pattern)
+ .or().like("trace_topic", pattern)
.or().like("queried_by", pattern))
.orderByDesc("gmt_create", "id");
Page<RmqTraceQuery> result = traceQueryMapper.selectPage(new
Page<>(page, pageSize), query);
@@ -320,6 +332,7 @@ public class QueryHistoryService {
private static TraceQueryHistoryVO toTraceHistory(RmqTraceQuery query) {
return TraceQueryHistoryVO.builder()
.id(query.getId()).msgId(query.getMsgId()).topic(query.getTopic())
+ .traceTopic(query.getTraceTopic())
.nodeCount(query.getNodeCount() == null ? 0 :
query.getNodeCount())
.consumerCount(query.getConsumerCount() == null ? 0 :
query.getConsumerCount())
.clusterId(query.getClusterId()).queriedBy(query.getQueriedBy())
@@ -336,4 +349,8 @@ public class QueryHistoryService {
}
return search.replace("\\", "\\\\").replace("%", "\\%").replace("_",
"\\_");
}
+
+ private static String normalizeOptional(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
index 68413f6e1..85fc15e70 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/TraceQueryHistoryVO.java
@@ -17,6 +17,7 @@ public class TraceQueryHistoryVO {
private Long id;
private String msgId;
private String topic;
+ private String traceTopic;
private int nodeCount;
private int consumerCount;
private String clusterId;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTraceQuery.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTraceQuery.java
index 7cab2007d..4374f216e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTraceQuery.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqTraceQuery.java
@@ -34,6 +34,13 @@ public class RmqTraceQuery {
private String topic;
+ /**
+ * The trace topic selected for the lookup. A null value means that the
provider default
+ * (normally {@code RMQ_SYS_TRACE_TOPIC}) was used. Keeping this nullable
preserves the
+ * meaning of history rows written before custom trace-topic support
existed.
+ */
+ private String traceTopic;
+
private Integer nodeCount;
private Integer consumerCount;
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 31f2e0938..4e21248cf 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS rmq_instance_trace (
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
CURRENT_TIMESTAMP COMMENT '修改时间',
msg_id VARCHAR(128) NOT NULL,
topic VARCHAR(255),
+ trace_topic VARCHAR(255) COMMENT '自定义消息轨迹主题,空表示使用默认主题',
node_count INT DEFAULT 0,
consumer_count INT DEFAULT 0,
cluster_id VARCHAR(255),
@@ -428,5 +429,5 @@ ALTER TABLE rmq_instance_message MODIFY queried_by
VARCHAR(128);
ALTER TABLE rmq_instance_trace MODIFY queried_by VARCHAR(128);
ALTER TABLE rmq_operation_audit MODIFY operator VARCHAR(128);
--- Existing deployments are upgraded by AlertSchemaMigration after the
application
--- connects, because this schema is also parsed by H2 in the development
profile.
+-- Existing deployments are upgraded by the application schema-migration
runners after the
+-- application connects. This schema is also parsed by H2 in the development
profile.
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
index b79acc8a2..c2683a7de 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
@@ -267,6 +267,26 @@ class MessageServiceTest {
verifyNoInteractions(fallback);
}
+ @Test
+ void recordsTheCustomTraceTopicUsedByMessageIdLookup() {
+ MessageProvider fallback = mock(MessageProvider.class);
+ InstanceProvider provider = mock(InstanceProvider.class);
+ InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
+ QueryHistoryService history = mock(QueryHistoryService.class);
+ MessageService service = new MessageService(fallback, registry,
history, mock(OperationAuditService.class));
+ TraceRecordVO trace =
TraceRecordVO.builder().nodes(List.of()).consumerStatus(List.of()).build();
+
when(registry.byInstanceId("instance-a")).thenReturn(Optional.of(provider));
+ when(provider.getMessageTrace("instance-a", "msg-001", "orders",
"CUSTOM_TRACE"))
+ .thenReturn(trace);
+
+ assertThat(service.getMessageTrace("instance-a", "msg-001", "orders",
" CUSTOM_TRACE "))
+ .isSameAs(trace);
+
+ verify(provider).getMessageTrace("instance-a", "msg-001", "orders",
"CUSTOM_TRACE");
+ verify(history).recordTraceQuery("instance-a", "msg-001", "orders",
"CUSTOM_TRACE", 0, 0);
+ verifyNoInteractions(fallback);
+ }
+
@Test
void keyTraceLookupDoesNotRecordTraceQueryHistory() {
MessageProvider fallback = mock(MessageProvider.class);
@@ -285,4 +305,22 @@ class MessageServiceTest {
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt());
}
+
+ @Test
+ void normalizesCustomTraceTopicForKeyLookup() {
+ MessageProvider fallback = mock(MessageProvider.class);
+ InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
+ QueryHistoryService history = mock(QueryHistoryService.class);
+ MessageService service = new MessageService(fallback, registry,
history, mock(OperationAuditService.class));
+ TraceRecordVO trace =
TraceRecordVO.builder().nodes(List.of()).consumerStatus(List.of()).build();
+ when(registry.byInstanceId("instance-a")).thenReturn(Optional.empty());
+ when(fallback.getMessageTraceByKey("instance-a", "ORDER-1", "orders",
"CUSTOM_TRACE"))
+ .thenReturn(trace);
+
+ assertThat(service.getMessageTraceByKey("instance-a", "ORDER-1",
"orders", " CUSTOM_TRACE "))
+ .isSameAs(trace);
+
+ verify(fallback).getMessageTraceByKey("instance-a", "ORDER-1",
"orders", "CUSTOM_TRACE");
+ verifyNoInteractions(history);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigrationTest.java
new file mode 100644
index 000000000..4292251e1
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistorySchemaMigrationTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.message;
+
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class QueryHistorySchemaMigrationTest {
+
+ @Test
+ void addsTraceTopicToExistingHistoryWithoutDroppingRowsAndIsIdempotent()
throws Exception {
+ JdbcDataSource dataSource = new JdbcDataSource();
+
dataSource.setURL("jdbc:h2:mem:trace-history-schema-migration;MODE=MySQL;"
+ + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE rmq_instance_trace ("
+ + "id BIGINT PRIMARY KEY, msg_id VARCHAR(128) NOT NULL,
topic VARCHAR(255), "
+ + "node_count INT, consumer_count INT, cluster_id
VARCHAR(255), "
+ + "queried_by VARCHAR(128), gmt_create TIMESTAMP,
gmt_modified TIMESTAMP)");
+ statement.execute("INSERT INTO rmq_instance_trace (id, msg_id,
topic) "
+ + "VALUES (1, 'old-msg', 'orders')");
+ }
+
+ QueryHistorySchemaMigration migration = new
QueryHistorySchemaMigration(dataSource);
+ migration.run(new DefaultApplicationArguments());
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ try (ResultSet columns = statement.executeQuery("SELECT COUNT(*)
FROM information_schema.columns "
+ + "WHERE table_name = 'rmq_instance_trace' AND column_name
= 'trace_topic'")) {
+ columns.next();
+ assertThat(columns.getInt(1)).isEqualTo(1);
+ }
+ try (ResultSet rows = statement.executeQuery("SELECT msg_id,
trace_topic FROM rmq_instance_trace")) {
+ rows.next();
+ assertThat(rows.getString("msg_id")).isEqualTo("old-msg");
+ assertThat(rows.getString("trace_topic")).isNull();
+ assertThat(rows.next()).isFalse();
+ }
+ }
+ }
+
+ @Test
+ void skipsMissingHistoryTableSoCustomSchemaCanCreateItLater() throws
Exception {
+ JdbcDataSource dataSource = new JdbcDataSource();
+
dataSource.setURL("jdbc:h2:mem:trace-history-schema-missing;MODE=MySQL;"
+ + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+
+ QueryHistorySchemaMigration migration = new
QueryHistorySchemaMigration(dataSource);
+
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement();
+ ResultSet tables = connection.getMetaData().getTables(null,
null, "rmq_instance_trace",
+ new String[] {"TABLE"})) {
+ assertThat(tables.next()).isFalse();
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
index b369c9172..765735a7f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
@@ -51,7 +51,7 @@ class QueryHistoryServiceIntegrationTest {
AuthenticatedUserContext.setUser(longUsername, true);
queryHistoryService.recordMessageQuery("qh-cluster", "TOPIC",
"qh-topic",
null, null, null, null, null, 3, null);
- queryHistoryService.recordTraceQuery("qh-cluster", "qh-msg-id",
"qh-topic", 2, 1);
+ queryHistoryService.recordTraceQuery("qh-cluster", "qh-msg-id",
"qh-topic", "qh-trace-topic", 2, 1);
PageResult<MessageQueryHistoryVO> messageHistory =
queryHistoryService.listMessageQueries("qh-cluster", null,
null, 1, 20);
@@ -60,8 +60,10 @@ class QueryHistoryServiceIntegrationTest {
PageResult<TraceQueryHistoryVO> traceHistory =
queryHistoryService.listTraceQueries("qh-cluster", null,
1, 20);
- assertThat(traceHistory.getItems()).anySatisfy(item ->
- assertThat(item.getQueriedBy()).isEqualTo(longUsername));
+ assertThat(traceHistory.getItems()).anySatisfy(item -> {
+ assertThat(item.getQueriedBy()).isEqualTo(longUsername);
+ assertThat(item.getTraceTopic()).isEqualTo("qh-trace-topic");
+ });
} finally {
AuthenticatedUserContext.clear();
messageQueryMapper.delete(new QueryWrapper<RmqMessageQuery>()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
index b5a03a738..b29359f82 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceTest.java
@@ -80,6 +80,52 @@ class QueryHistoryServiceTest {
verify(traceQueryMapper).insert(captor.capture());
assertThat(captor.getValue().getClusterId()).isEqualTo("cluster-a");
assertThat(captor.getValue().getQueriedBy()).isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
+ assertThat(captor.getValue().getTraceTopic()).isNull();
+ }
+
+ @Test
+ void recordsNormalizedCustomTraceTopic() {
+ AuthenticatedUserContext.setUsername("alice");
+
+ service.recordTraceQuery("cluster-a", "msg-1", "orders", "
CUSTOM_TRACE ", 2, 1);
+
+ ArgumentCaptor<RmqTraceQuery> captor =
ArgumentCaptor.forClass(RmqTraceQuery.class);
+ verify(traceQueryMapper).insert(captor.capture());
+
assertThat(captor.getValue().getTraceTopic()).isEqualTo("CUSTOM_TRACE");
+ assertThat(captor.getValue().getQueriedBy()).isEqualTo("alice");
+ }
+
+ @Test
+ void mapsCustomTraceTopicIntoHistoryView() {
+ AuthenticatedUserContext.setUsername("alice");
+ RmqTraceQuery entity = new RmqTraceQuery();
+ entity.setId(7L);
+ entity.setMsgId("msg-7");
+ entity.setTopic("orders");
+ entity.setTraceTopic("CUSTOM_TRACE");
+ entity.setNodeCount(3);
+ entity.setConsumerCount(2);
+ entity.setClusterId("cluster-a");
+ entity.setQueriedBy("alice");
+ entity.setGmtCreate(LocalDateTime.of(2026, 8, 5, 12, 0));
+ when(traceQueryMapper.selectPage(any(Page.class), any(Wrapper.class)))
+ .thenAnswer(invocation -> {
+ Page<RmqTraceQuery> result = invocation.getArgument(0);
+ result.setRecords(java.util.List.of(entity));
+ result.setTotal(1);
+ return result;
+ });
+
+ PageResult<TraceQueryHistoryVO> result =
service.listTraceQueries("cluster-a", "CUSTOM", 1, 20);
+
+ assertThat(result.getItems()).singleElement().satisfies(item -> {
+ assertThat(item.getTraceTopic()).isEqualTo("CUSTOM_TRACE");
+ assertThat(item.getMsgId()).isEqualTo("msg-7");
+ });
+
+ ArgumentCaptor<Wrapper<RmqTraceQuery>> queryCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(traceQueryMapper).selectPage(any(Page.class),
queryCaptor.capture());
+
assertThat(queryCaptor.getValue().getCustomSqlSegment()).contains("trace_topic");
}
@Test
diff --git a/web/src/api/messageHistory.ts b/web/src/api/messageHistory.ts
index 538052a86..2c63a1063 100644
--- a/web/src/api/messageHistory.ts
+++ b/web/src/api/messageHistory.ts
@@ -32,6 +32,8 @@ export interface TraceQueryHistory {
id: number;
msgId: string;
topic?: string;
+ /** The custom trace topic used by the lookup; absent means the provider
default. */
+ traceTopic?: string;
nodeCount: number;
consumerCount: number;
clusterId?: string;
diff --git a/web/src/components/MessageQueryHistoryDrawer.tsx
b/web/src/components/MessageQueryHistoryDrawer.tsx
index 6914b28ce..fc25f3963 100644
--- a/web/src/components/MessageQueryHistoryDrawer.tsx
+++ b/web/src/components/MessageQueryHistoryDrawer.tsx
@@ -109,6 +109,12 @@ const MessageQueryHistoryDrawer = ({
const traceColumns: ColumnsType<TraceQueryHistory> = [
{ title: 'Message ID', dataIndex: 'msgId', ellipsis: true },
{ title: 'Topic', dataIndex: 'topic', ellipsis: true },
+ {
+ title: '轨迹 Topic',
+ dataIndex: 'traceTopic',
+ ellipsis: true,
+ render: (value?: string) => value?.trim() || '默认',
+ },
{ title: '轨迹节点', dataIndex: 'nodeCount', width: 90 },
{ title: '消费者', dataIndex: 'consumerCount', width: 90 },
{ title: '操作者', dataIndex: 'queriedBy', width: 110 },
@@ -124,7 +130,7 @@ const MessageQueryHistoryDrawer = ({
</Flex>
<Input.Search
allowClear
- placeholder="搜索 Topic、Message ID、Key 或操作者"
+ placeholder="搜索 Topic、轨迹 Topic、Message ID、Key 或操作者"
onSearch={(value) => {
setPage(1);
setSearch(value.trim());
diff --git a/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
b/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
index b1fad3a60..474234be4 100644
--- a/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
+++ b/web/src/components/__tests__/MessageQueryHistoryDrawer.test.tsx
@@ -60,6 +60,7 @@ describe('MessageQueryHistoryDrawer', () => {
id: 2,
msgId: 'msg-1',
topic: 'orders',
+ traceTopic: 'CUSTOM_TRACE',
nodeCount: 3,
consumerCount: 1,
queriedBy: 'bob',
@@ -86,6 +87,7 @@ describe('MessageQueryHistoryDrawer', () => {
);
await user.click(screen.getByRole('tab', { name: '轨迹查询' }));
expect(await screen.findByText('msg-1')).toBeInTheDocument();
+ expect(screen.getByText('CUSTOM_TRACE')).toBeInTheDocument();
await waitFor(() => expect(listTraceQueryHistory).toHaveBeenCalled());
});
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 260ee8f88..a8470739d 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -33,6 +33,11 @@ const serviceMocks = vi.hoisted(() => ({
const instanceFilterMocks = vi.hoisted(() => ({
useInstanceFilter: vi.fn(),
}));
+const historyMocks = vi.hoisted(() => ({
+ getQueryHistorySummary: vi.fn(),
+ listMessageQueryHistory: vi.fn(),
+ listTraceQueryHistory: vi.fn(),
+}));
vi.mock('../../../services/messageService', () => ({
...serviceMocks,
@@ -51,6 +56,8 @@ vi.mock('../../../services/messageService', () => ({
}));
vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
+vi.mock('../../../api/messageHistory', () => historyMocks);
+
vi.mock('../../../services/instanceService', () => ({
listInstances: vi.fn().mockResolvedValue([]),
}));
@@ -137,8 +144,22 @@ const selectTopic = async (user: ReturnType<typeof
userEvent.setup>) => {
describe('MessagePage async request ownership', () => {
beforeEach(() => {
vi.clearAllMocks();
+ localStorage.clear();
serviceMocks.getMessageTrace.mockResolvedValue(null);
serviceMocks.getMessageTraceByKey.mockResolvedValue(null);
+ historyMocks.getQueryHistorySummary.mockResolvedValue({ messageQueries: 0,
traceQueries: 0 });
+ historyMocks.listMessageQueryHistory.mockResolvedValue({
+ items: [],
+ total: 0,
+ page: 1,
+ size: 20,
+ });
+ historyMocks.listTraceQueryHistory.mockResolvedValue({
+ items: [],
+ total: 0,
+ page: 1,
+ size: 20,
+ });
instanceFilterMocks.useInstanceFilter.mockReturnValue({
selectedInstanceId: 1,
selectInstance: vi.fn(),
@@ -439,6 +460,112 @@ describe('MessagePage async request ownership', () => {
expect(within(dialog).getByText(/cg-notification/)).toBeInTheDocument();
});
+ it('remembers a custom trace topic per instance across page remounts', async
() => {
+
serviceMocks.queryMessages.mockResolvedValue([createMessage('remembered-message')]);
+ const user = userEvent.setup();
+ const firstRender = renderPage();
+ await selectTopic(user);
+
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const firstRow = await screen.findByRole('row', { name:
/remembered-message/ });
+ await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
+ const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ const firstTraceTopicInput =
+ within(firstDialog).getByPlaceholderText('轨迹 Topic(留空使用默认)');
+ await user.type(firstTraceTopicInput, ' CUSTOM_TRACE ');
+
+ await waitFor(() => {
+
expect(localStorage.getItem('rocketmq-studio-message-trace-topic:1')).toBe('CUSTOM_TRACE');
+ });
+
+ firstRender.unmount();
+ renderPage();
+ await selectTopic(user);
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const secondRow = await screen.findByRole('row', { name:
/remembered-message/ });
+ await user.click(within(secondRow).getByRole('button', { name: /轨迹/ }));
+ const secondDialog = await screen.findByRole('dialog', { name: '消息详情' });
+
+ expect(within(secondDialog).getByPlaceholderText('轨迹
Topic(留空使用默认)')).toHaveValue(
+ 'CUSTOM_TRACE',
+ );
+ });
+
+ it('does not leak a stored custom trace topic between instances', async ()
=> {
+ localStorage.setItem('rocketmq-studio-message-trace-topic:1', 'TRACE_A');
+ localStorage.setItem('rocketmq-studio-message-trace-topic:2', 'TRACE_B');
+
serviceMocks.queryMessages.mockResolvedValue([createMessage('instance-message')]);
+ let currentInstanceId = 1;
+ instanceFilterMocks.useInstanceFilter.mockImplementation(() => ({
+ selectedInstanceId: currentInstanceId,
+ selectInstance: vi.fn(),
+ instanceOptions: [
+ { value: 1, label: 'Instance A' },
+ { value: 2, label: 'Instance B' },
+ ],
+ }));
+ const user = userEvent.setup();
+ const view = renderPage();
+ await selectTopic(user);
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const firstRow = await screen.findByRole('row', { name: /instance-message/
});
+ await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
+ const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(within(firstDialog).getByPlaceholderText('轨迹
Topic(留空使用默认)')).toHaveValue(
+ 'TRACE_A',
+ );
+
+ currentInstanceId = 2;
+ view.rerender(<MessagePageWithProviders />);
+ await selectTopic(user);
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const secondRow = await screen.findByRole('row', { name:
/instance-message/ });
+ await user.click(within(secondRow).getByRole('button', { name: /轨迹/ }));
+ const secondDialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(within(secondDialog).getByPlaceholderText('轨迹
Topic(留空使用默认)')).toHaveValue(
+ 'TRACE_B',
+ );
+ });
+
+ it('restores a custom trace topic from history before opening the trace
again', async () => {
+ historyMocks.listTraceQueryHistory.mockResolvedValue({
+ items: [
+ {
+ id: 9,
+ msgId: 'history-message',
+ topic: 'orders',
+ traceTopic: 'CUSTOM_TRACE',
+ nodeCount: 1,
+ consumerCount: 0,
+ queriedBy: 'alice',
+ queriedAt: '2026-08-05T12:00:00Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+
serviceMocks.queryMessages.mockResolvedValue([createMessage('history-message')]);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /服务端历史/ }));
+ await user.click(await screen.findByRole('tab', { name: '轨迹查询' }));
+ await user.click(await screen.findByText('history-message'));
+
+ const row = await screen.findByRole('row', { name: /history-message/ });
+ await user.click(within(row).getByRole('button', { name: /轨迹/ }));
+
+ await waitFor(() => {
+ expect(serviceMocks.getMessageTrace).toHaveBeenLastCalledWith(
+ 'history-message',
+ 1,
+ 'topic-history-message',
+ 'CUSTOM_TRACE',
+ );
+ });
+ });
+
it('keeps the latest query loading and ignores an earlier query result',
async () => {
const firstQuery = createDeferred<MessageRecord[]>();
const secondQuery = createDeferred<MessageRecord[]>();
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index a90a84cf6..3d23be5ea 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -71,6 +71,10 @@ import {
import { listTopics } from '../../services/topicService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { downloadBlob } from '../../utils/download';
+import {
+ readMessageTraceTopic,
+ writeMessageTraceTopic,
+} from '../../utils/messageTraceTopicStorage';
import { tableScrollX } from '../../utils/table';
import {
analyzeMessageTrace,
@@ -397,7 +401,9 @@ const MessagePageContent = ({
const [traceError, setTraceError] = useState<string | null>(null);
const [traceQueryMode, setTraceQueryMode] = useState<'msgid' |
'key'>('msgid');
const [traceQueryValue, setTraceQueryValue] = useState('');
- const [customTraceTopic, setCustomTraceTopic] = useState('');
+ const [customTraceTopic, setCustomTraceTopic] = useState(() =>
+ readMessageTraceTopic(selectedInstanceId),
+ );
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [directConsumeOpen, setDirectConsumeOpen] = useState(false);
const [directConsumeGroup, setDirectConsumeGroup] = useState('');
@@ -416,6 +422,10 @@ const MessagePageContent = ({
[],
);
+ useEffect(() => {
+ writeMessageTraceTopic(selectedInstanceId, customTraceTopic);
+ }, [customTraceTopic, selectedInstanceId]);
+
const currentQueryParams: MessageQuery =
queryMode === 'topic'
? { topic: selectedTopic, startTime: dateRange[0].valueOf(), endTime:
dateRange[1].valueOf() }
@@ -562,6 +572,7 @@ const MessagePageContent = ({
handleQueryModeChange('msgid');
setSelectedTopic(record.topic);
setMsgIdInput(record.msgId);
+ setCustomTraceTopic(record.traceTopic?.trim() || '');
setHistoryDrawerOpen(false);
void executeQuery('msgid', { topic: record.topic, msgId: record.msgId });
};
@@ -577,15 +588,24 @@ const MessagePageContent = ({
setTraceError(null);
setTraceQueryMode('msgid');
setTraceQueryValue(record.msgId);
- const cacheKey = JSON.stringify([selectedInstanceId, record.topic,
record.msgId]);
+ const normalizedTraceTopic = customTraceTopic.trim();
+ const cacheKey = JSON.stringify([
+ selectedInstanceId,
+ record.topic,
+ record.msgId,
+ normalizedTraceTopic,
+ ]);
let traceRequest = traceCacheRef.current.get(cacheKey);
if (!traceRequest) {
- traceRequest = getMessageTrace(record.msgId, selectedInstanceId,
record.topic).catch(
- (error) => {
- traceCacheRef.current.delete(cacheKey);
- throw error;
- },
- );
+ traceRequest = getMessageTrace(
+ record.msgId,
+ selectedInstanceId,
+ record.topic,
+ normalizedTraceTopic,
+ ).catch((error) => {
+ traceCacheRef.current.delete(cacheKey);
+ throw error;
+ });
traceCacheRef.current.set(cacheKey, traceRequest);
}
try {
diff --git a/web/src/utils/messageTraceTopicStorage.test.ts
b/web/src/utils/messageTraceTopicStorage.test.ts
new file mode 100644
index 000000000..637bf1f7e
--- /dev/null
+++ b/web/src/utils/messageTraceTopicStorage.test.ts
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { readMessageTraceTopic, writeMessageTraceTopic } from
'./messageTraceTopicStorage';
+
+describe('message trace topic storage', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('stores normalized topics independently for each instance', () => {
+ writeMessageTraceTopic('instance/a', ' CUSTOM_TRACE ');
+ writeMessageTraceTopic('instance-b', 'OTHER_TRACE');
+
+ expect(readMessageTraceTopic('instance/a')).toBe('CUSTOM_TRACE');
+ expect(readMessageTraceTopic('instance-b')).toBe('OTHER_TRACE');
+ expect(readMessageTraceTopic('instance-c')).toBe('');
+ });
+
+ it('removes blank topics so the provider default is restored', () => {
+ writeMessageTraceTopic('instance-a', 'CUSTOM_TRACE');
+
+ writeMessageTraceTopic('instance-a', ' ');
+
+ expect(readMessageTraceTopic('instance-a')).toBe('');
+ });
+
+ it('treats denied browser storage as an optional preference', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new DOMException('storage denied', 'SecurityError');
+ });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('storage denied', 'SecurityError');
+ });
+
+ expect(() => writeMessageTraceTopic('instance-a',
'CUSTOM_TRACE')).not.toThrow();
+ expect(readMessageTraceTopic('instance-a')).toBe('');
+ });
+});
diff --git a/web/src/utils/messageTraceTopicStorage.ts
b/web/src/utils/messageTraceTopicStorage.ts
new file mode 100644
index 000000000..85345f436
--- /dev/null
+++ b/web/src/utils/messageTraceTopicStorage.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const STORAGE_KEY_PREFIX = 'rocketmq-studio-message-trace-topic:';
+
+const normalizeInstanceId = (instanceId: string | number | undefined): string
| undefined => {
+ if (instanceId === undefined || instanceId === null) return undefined;
+ const normalized = String(instanceId).trim();
+ return normalized || undefined;
+};
+
+const storageKey = (instanceId: string | number | undefined): string |
undefined => {
+ const normalized = normalizeInstanceId(instanceId);
+ return normalized ? `${STORAGE_KEY_PREFIX}${encodeURIComponent(normalized)}`
: undefined;
+};
+
+const getStorage = (): Storage | null => {
+ if (typeof window === 'undefined') return null;
+ try {
+ return window.localStorage;
+ } catch {
+ // Browsers can deny storage access in private mode or under a restrictive
policy.
+ return null;
+ }
+};
+
+/**
+ * Reads the last custom trace topic used for one instance.
+ * A missing, blank, or unreadable value intentionally means provider default.
+ */
+export const readMessageTraceTopic = (instanceId: string | number |
undefined): string => {
+ const key = storageKey(instanceId);
+ const storage = getStorage();
+ if (!key || !storage) return '';
+ try {
+ return storage.getItem(key)?.trim() || '';
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * Persists a normalized custom trace topic without making browser storage a
prerequisite for
+ * querying traces. Blank values remove the per-instance preference and
restore default behavior.
+ */
+export const writeMessageTraceTopic = (
+ instanceId: string | number | undefined,
+ traceTopic: string | undefined,
+): void => {
+ const key = storageKey(instanceId);
+ const storage = getStorage();
+ if (!key || !storage) return;
+ const normalized = traceTopic?.trim() || '';
+ try {
+ if (normalized) {
+ storage.setItem(key, normalized);
+ } else {
+ storage.removeItem(key);
+ }
+ } catch {
+ // Storage failure must not block an otherwise valid trace request.
+ }
+};