This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/2.0.0 by this push:
     new faf056464b Add operation context to related metrics
faf056464b is described below

commit faf056464b6b0b6e890c8a374f78662e21e5d336
Author: Logic <[email protected]>
AuthorDate: Mon Jun 8 10:55:03 2026 +0800

    Add operation context to related metrics
---
 .../dto/metrics/OtlpRelatedMetricsDto.java         |  2 +
 .../controller/OtlpIngestionController.java        |  3 +-
 .../service/OtlpIngestionWorkspaceService.java     |  3 +-
 .../impl/OtlpIngestionWorkspaceServiceImpl.java    | 40 ++++++++++++++++----
 .../controller/OtlpIngestionControllerTest.java    |  7 +++-
 .../OtlpIngestionWorkspaceServiceImplTest.java     | 43 ++++++++++++++++++++++
 web-next/app/log/manage/log-manage-page.tsx        | 13 +++++--
 web-next/app/log/manage/page.test.tsx              | 11 +++++-
 .../app/trace/manage/trace-manage-client.test.tsx  | 18 ++++++++-
 web-next/app/trace/manage/trace-manage-page.tsx    |  3 +-
 web-next/lib/log-manage/view-model.test.ts         | 27 ++++++++++++++
 web-next/lib/log-manage/view-model.ts              |  7 ++++
 web-next/lib/signal-route-context.ts               |  1 +
 web-next/lib/trace-manage/view-model.test.ts       |  2 +
 web-next/lib/trace-manage/view-model.ts            |  2 +
 web-next/lib/types.ts                              |  1 +
 16 files changed, 164 insertions(+), 19 deletions(-)

diff --git 
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpRelatedMetricsDto.java
 
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpRelatedMetricsDto.java
index b76bbfe0f3..081af7b232 100644
--- 
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpRelatedMetricsDto.java
+++ 
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpRelatedMetricsDto.java
@@ -35,6 +35,8 @@ public class OtlpRelatedMetricsDto {
 
     private String filter;
 
+    private String operationName;
+
     private String source;
 
     private int candidateCount;
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
index eee0b2b9a4..e61671f9c8 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
@@ -107,8 +107,9 @@ public class OtlpIngestionController {
             @RequestParam(value = "serviceNamespace", required = false) String 
serviceNamespace,
             @RequestParam(value = "environment", required = false) String 
environment,
             @RequestParam(value = "filter", required = false) String filter,
+            @RequestParam(value = "operationName", required = false) String 
operationName,
             @RequestParam(value = "limit", required = false) String limit) {
         return 
ResponseEntity.ok(Message.success(otlpIngestionWorkspaceService.getRelatedMetrics(
-                entityId, entityType, start, end, serviceName, 
serviceNamespace, environment, filter, limit)));
+                entityId, entityType, start, end, serviceName, 
serviceNamespace, environment, filter, operationName, limit)));
     }
 }
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
index f541f227c0..fc2eaf37d9 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
@@ -41,5 +41,6 @@ public interface OtlpIngestionWorkspaceService {
                                             String temporalAggregation, String 
step, String limit);
 
     OtlpRelatedMetricsDto getRelatedMetrics(Long entityId, String entityType, 
Long start, Long end, String serviceName,
-                                            String serviceNamespace, String 
environment, String filter, String limit);
+                                            String serviceNamespace, String 
environment, String filter,
+                                            String operationName, String 
limit);
 }
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
index 72a3b7ea36..0ef6bf930c 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
@@ -778,7 +778,7 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
     @Override
     public OtlpRelatedMetricsDto getRelatedMetrics(Long entityId, String 
entityType, Long start, Long end, String serviceName,
                                                    String serviceNamespace, 
String environment,
-                                                   String filter, String 
limit) {
+                                                   String filter, String 
operationName, String limit) {
         long resolvedEnd = end == null || end <= 0 ? 
System.currentTimeMillis() : end;
         long resolvedStart = start == null || start <= 0 || start >= 
resolvedEnd
                 ? resolvedEnd - DEFAULT_CONSOLE_LOOKBACK_MILLIS
@@ -788,13 +788,15 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
                 entityId, entityType, resolvedStart, resolvedEnd, serviceName, 
serviceNamespace, environment
         );
         String normalizedFilter = trimToNull(filter);
+        String normalizedOperationName = trimToNull(operationName);
         List<OtlpRelatedMetricsDto.ResourceMatcher> resourceMatchers = 
parseRelatedMetricResourceMatchers(normalizedFilter);
         List<OtlpRelatedMetricsDto.Candidate> candidates = 
buildRelatedMetricCandidates(
-                context, resourceMatchers, resolvedLimit
+                context, resourceMatchers, normalizedOperationName, 
resolvedLimit
         );
         return new OtlpRelatedMetricsDto(
                 context,
                 normalizedFilter,
+                normalizedOperationName,
                 "backend-related-metrics",
                 candidates.size(),
                 resourceMatchers,
@@ -805,12 +807,13 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
     public OtlpRelatedMetricsDto getRelatedMetrics(Long entityId, Long start, 
Long end, String serviceName,
                                                    String serviceNamespace, 
String environment,
                                                    String filter, String 
limit) {
-        return getRelatedMetrics(entityId, null, start, end, serviceName, 
serviceNamespace, environment, filter, limit);
+        return getRelatedMetrics(entityId, null, start, end, serviceName, 
serviceNamespace, environment, filter, null, limit);
     }
 
     private List<OtlpRelatedMetricsDto.Candidate> buildRelatedMetricCandidates(
             OtlpMetricsConsoleDto.Context context,
             List<OtlpRelatedMetricsDto.ResourceMatcher> resourceMatchers,
+            String operationName,
             int limit) {
         LinkedHashMap<String, OtlpRelatedMetricsDto.Candidate> candidates = 
new LinkedHashMap<>();
         Map<String, String> resourceMatch = 
resourceMatcherValueMap(resourceMatchers);
@@ -837,11 +840,15 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
             addRelatedMetricCandidate(
                     candidates,
                     normalizePromqlMetricName(metricName),
-                    "service",
+                    StringUtils.hasText(operationName) ? "operation" : 
"service",
                     relatedMetricFamily(metricName),
-                    "service-context",
-                    serviceContextLabels(context),
-                    serviceContextResourceMatch(context)
+                    StringUtils.hasText(operationName) ? "operation-context" : 
"service-context",
+                    StringUtils.hasText(operationName)
+                            ? operationContextLabels(context, operationName)
+                            : serviceContextLabels(context),
+                    StringUtils.hasText(operationName)
+                            ? operationContextResourceMatch(context, 
operationName)
+                            : serviceContextResourceMatch(context)
             );
             if (candidates.size() >= limit) {
                 return List.copyOf(candidates.values());
@@ -951,6 +958,15 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
         return List.copyOf(labels);
     }
 
+    private List<String> operationContextLabels(OtlpMetricsConsoleDto.Context 
context, String operationName) {
+        List<String> labels = new ArrayList<>(serviceContextLabels(context));
+        if (StringUtils.hasText(operationName)) {
+            labels.add("operation_name");
+            labels.add("http_route");
+        }
+        return List.copyOf(new LinkedHashSet<>(labels));
+    }
+
     private Map<String, String> 
serviceContextResourceMatch(OtlpMetricsConsoleDto.Context context) {
         if (context == null || !StringUtils.hasText(context.getServiceName())) 
{
             return Map.of();
@@ -972,6 +988,16 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
         return values;
     }
 
+    private Map<String, String> 
operationContextResourceMatch(OtlpMetricsConsoleDto.Context context, String 
operationName) {
+        LinkedHashMap<String, String> values = new 
LinkedHashMap<>(serviceContextResourceMatch(context));
+        String normalizedOperationName = trimToNull(operationName);
+        if (StringUtils.hasText(normalizedOperationName)) {
+            values.put("operation_name", normalizedOperationName);
+            values.put("http_route", normalizedOperationName);
+        }
+        return values;
+    }
+
     private String relatedMetricFamily(String metricName) {
         String normalized = trimToNull(metricName);
         if (!StringUtils.hasText(normalized)) {
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
index 05a3a8064f..ecf3505c0a 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
@@ -266,6 +266,7 @@ class OtlpIngestionControllerTest {
                 new OtlpMetricsConsoleDto.Context(42L, "service", "Checkout 
API", "checkout", "commerce", "prod",
                         1000L, 2000L),
                 "k8s.pod.name=\"checkout-7d9\"",
+                "POST /checkout",
                 "backend-related-metrics",
                 1,
                 List.of(new 
OtlpRelatedMetricsDto.ResourceMatcher("k8s_pod_name", "=", "checkout-7d9")),
@@ -279,7 +280,7 @@ class OtlpIngestionControllerTest {
                 ))
         );
         when(otlpIngestionWorkspaceService.getRelatedMetrics(42L, "service", 
1000L, 2000L, "checkout", "commerce", "prod",
-                "k8s.pod.name=\"checkout-7d9\"", "8")).thenReturn(related);
+                "k8s.pod.name=\"checkout-7d9\"", "POST /checkout", 
"8")).thenReturn(related);
 
         mockMvc.perform(get("/api/ingestion/otlp/metrics/related")
                         .param("entityId", "42")
@@ -290,11 +291,13 @@ class OtlpIngestionControllerTest {
                         .param("serviceNamespace", "commerce")
                         .param("environment", "prod")
                         .param("filter", "k8s.pod.name=\"checkout-7d9\"")
+                        .param("operationName", "POST /checkout")
                         .param("limit", "8"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(0))
                 .andExpect(jsonPath("$.data.context.entityId").value(42))
                 
.andExpect(jsonPath("$.data.context.entityType").value("service"))
+                .andExpect(jsonPath("$.data.operationName").value("POST 
/checkout"))
                 
.andExpect(jsonPath("$.data.source").value("backend-related-metrics"))
                 
.andExpect(jsonPath("$.data.resourceMatchers[0].label").value("k8s_pod_name"))
                 
.andExpect(jsonPath("$.data.candidates[0].query").value("container.cpu.usage"))
@@ -302,6 +305,6 @@ class OtlpIngestionControllerTest {
 
         verify(otlpIngestionWorkspaceService)
                 .getRelatedMetrics(42L, "service", 1000L, 2000L, "checkout", 
"commerce", "prod",
-                        "k8s.pod.name=\"checkout-7d9\"", "8");
+                        "k8s.pod.name=\"checkout-7d9\"", "POST /checkout", 
"8");
     }
 }
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
index d65f8c2368..eaa33fe3ff 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
@@ -986,6 +986,7 @@ class OtlpIngestionWorkspaceServiceImplTest {
                 "commerce",
                 "prod",
                 "k8s.pod.name=\"checkout-7d9\" and host.name=\"node-a\"",
+                null,
                 "8"
         );
 
@@ -1010,6 +1011,48 @@ class OtlpIngestionWorkspaceServiceImplTest {
         assertEquals(related.getCandidates().size(), 
related.getCandidateCount());
     }
 
+    @Test
+    void relatedMetricsReturnsOperationScopedServiceCandidates() {
+        observabilitySignalIntakeGateway.recordOtlpMetricIntake(
+                Map.of(
+                        "service.name", "checkout",
+                        "service.namespace", "commerce",
+                        "deployment.environment.name", "prod"
+                ),
+                2_000L,
+                "http.server.duration",
+                "histogram",
+                "ms",
+                14.0,
+                Map.of()
+        );
+        
when(workspaceQueryGateway.findEntityById(42L)).thenReturn(java.util.Optional.empty());
+        
when(workspaceQueryGateway.findIdentitiesByEntityId(42L)).thenReturn(List.of());
+
+        OtlpRelatedMetricsDto related = 
otlpIngestionWorkspaceService.getRelatedMetrics(
+                42L,
+                "service",
+                1_000L,
+                2_000L,
+                "checkout",
+                "commerce",
+                "prod",
+                "k8s.pod.name=\"checkout-7d9\"",
+                "POST /checkout",
+                "8"
+        );
+
+        assertEquals("POST /checkout", related.getOperationName());
+        assertTrue(related.getCandidates().stream().anyMatch(candidate ->
+                "http_server_duration".equals(candidate.getQuery())
+                        && "operation".equals(candidate.getSource())
+                        && "latency".equals(candidate.getFamily())
+                        && "operation-context".equals(candidate.getReason())
+                        && 
candidate.getMatchedLabels().contains("operation_name")
+                        && "POST 
/checkout".equals(candidate.getResourceMatch().get("operation_name"))
+                        && "POST 
/checkout".equals(candidate.getResourceMatch().get("http_route"))));
+    }
+
     @Test
     void metricsConsoleAppliesFilterWhenQueryIsExplicitMetricName() {
         String expectedQuery = 
groupedMetricPromql("__name__=\"http_server_request_duration_count\", "
diff --git a/web-next/app/log/manage/log-manage-page.tsx 
b/web-next/app/log/manage/log-manage-page.tsx
index 7b985942b2..298b49affa 100644
--- a/web-next/app/log/manage/log-manage-page.tsx
+++ b/web-next/app/log/manage/log-manage-page.tsx
@@ -635,7 +635,7 @@ function buildLogMetricsPreviewApiUrl(metricsHref: string | 
null | undefined, qu
   return `/ingestion/otlp/metrics/console?${params.toString()}`;
 }
 
-function buildLogMetricsRelatedApiUrl(metricsHref: string | null | undefined) {
+function buildLogMetricsRelatedApiUrl(metricsHref: string | null | undefined, 
operationName?: string | null) {
   if (!metricsHref) return null;
   const href = new URL(metricsHref, 'http://localhost');
   const sourceParams = href.searchParams;
@@ -651,11 +651,16 @@ function buildLogMetricsRelatedApiUrl(metricsHref: string 
| null | undefined) {
     'entityName',
     'serviceName',
     'serviceNamespace',
-    'environment'
+    'environment',
+    'operationName'
   ].forEach(key => {
     const value = sourceParams.get(key)?.trim();
     if (value) params.set(key, value);
   });
+  const operationNameFallback = operationName?.trim();
+  if (!params.get('operationName') && operationNameFallback) {
+    params.set('operationName', operationNameFallback);
+  }
   if (!params.get('serviceName') && !params.get('entityId')) return null;
   params.set('limit', '8');
   return `/ingestion/otlp/metrics/related?${params.toString()}`;
@@ -1677,8 +1682,8 @@ function LogManageExplorer({
     [detailHandoffLinks.metricsHref, detailLog]
   );
   const detailMetricsRelatedUrl = useMemo(
-    () => buildLogMetricsRelatedApiUrl(detailLog ? 
detailHandoffLinks.metricsHref : null),
-    [detailHandoffLinks.metricsHref, detailLog]
+    () => buildLogMetricsRelatedApiUrl(detailLog ? 
detailHandoffLinks.metricsHref : null, routeContext.operationName),
+    [detailHandoffLinks.metricsHref, detailLog, routeContext.operationName]
   );
   const detailContextRows = useMemo(
     () => buildLogDetailContextRows(detailContextState.data, detailLog, t),
diff --git a/web-next/app/log/manage/page.test.tsx 
b/web-next/app/log/manage/page.test.tsx
index 10e84c1e3e..373aa28e19 100644
--- a/web-next/app/log/manage/page.test.tsx
+++ b/web-next/app/log/manage/page.test.tsx
@@ -556,7 +556,8 @@ function buildLogManageRouteState(): LogManageRouteState {
       tz: mockState.searchParams.get('tz') || undefined,
       source: mockState.searchParams.get('source') || undefined,
       traceId: mockState.searchParams.get('traceId') || undefined,
-      spanId: mockState.searchParams.get('spanId') || undefined
+      spanId: mockState.searchParams.get('spanId') || undefined,
+      operationName: mockState.searchParams.get('operationName') || undefined
     },
     shouldCleanUrl: Boolean(mockState.searchParams.get('returnLabel') || 
mockState.searchParams.get('returnTo')?.includes('returnLabel='))
   };
@@ -2767,7 +2768,7 @@ describe('log manage page', () => {
   });
 
   it('uses backend related metrics candidates before static fallback targets 
when the broad metrics query fails', async () => {
-    mockState.searchParams = new 
URLSearchParams('view=table&search=timeout&severityText=ERROR');
+    mockState.searchParams = new 
URLSearchParams('view=table&search=timeout&severityText=ERROR&operationName=POST%20%2Fcheckout');
     apiMessageGet.mockImplementation((path: string) => {
       if (path.startsWith('/ingestion/otlp/metrics/related')) {
         return Promise.resolve({
@@ -2866,6 +2867,10 @@ describe('log manage page', () => {
               'k8s.node.name': 'node-a',
               'k8s.container.name': 'checkout',
               'host.name': 'node-a'
+            },
+            attributes: {
+              ...mockState.renderData.list.content[0].attributes,
+              'http.route': 'POST /checkout'
             }
           }
         ]
@@ -2896,7 +2901,9 @@ describe('log manage page', () => {
     const relatedCalls = apiMessageGet.mock.calls.filter(call => 
String(call[0]).startsWith('/ingestion/otlp/metrics/related'));
     expect(relatedCalls).toHaveLength(1);
     const relatedHref = decodeURIComponent(String(relatedCalls[0]?.[0] || ''));
+    const relatedParams = new URL(String(relatedCalls[0]?.[0] || ''), 
'http://localhost').searchParams;
     expect(relatedHref).toContain('serviceName=checkout');
+    expect(relatedParams.get('operationName')).toBe('POST /checkout');
     expect(relatedHref).toContain('k8s.pod.name="checkout-7d9"');
     expect(relatedHref).toContain('host.name="node-a"');
     const metricsPreviewCalls = apiMessageGet.mock.calls.filter(call => 
String(call[0]).startsWith('/ingestion/otlp/metrics/console'));
diff --git a/web-next/app/trace/manage/trace-manage-client.test.tsx 
b/web-next/app/trace/manage/trace-manage-client.test.tsx
index 15078e74d0..d741513c32 100644
--- a/web-next/app/trace/manage/trace-manage-client.test.tsx
+++ b/web-next/app/trace/manage/trace-manage-client.test.tsx
@@ -194,7 +194,8 @@ describe('TraceManagePage client loading', () => {
       ])
       .mockResolvedValueOnce({
         source: 'backend-related-metrics',
-        candidateCount: 2,
+        operationName: 'POST /checkout',
+        candidateCount: 3,
         candidates: [
           {
             query: 'container.cpu.usage',
@@ -211,6 +212,14 @@ describe('TraceManagePage client loading', () => {
             reason: 'resource-filter',
             matchedLabels: ['host_name'],
             resourceMatch: { host_name: 'node-a' }
+          },
+          {
+            query: 'http.server.duration',
+            source: 'operation',
+            family: 'latency',
+            reason: 'operation-context',
+            matchedLabels: ['operation_name'],
+            resourceMatch: { operation_name: 'POST /checkout', http_route: 
'POST /checkout' }
           }
         ]
       })
@@ -275,8 +284,10 @@ describe('TraceManagePage client loading', () => {
     const relatedMetricsCall = apiMessageGet.mock.calls.find(call => 
String(call[0]).startsWith('/ingestion/otlp/metrics/related'));
     expect(relatedMetricsCall).toBeTruthy();
     const relatedMetricsHref = 
decodeURIComponent(String(relatedMetricsCall?.[0] || ''));
+    const relatedMetricsParams = new URL(String(relatedMetricsCall?.[0] || 
''), 'http://localhost').searchParams;
     expect(relatedMetricsHref).toContain('serviceName=checkout');
     expect(relatedMetricsHref).toContain('serviceNamespace=payments');
+    expect(relatedMetricsParams.get('operationName')).toBe('POST /checkout');
     expect(relatedMetricsHref).toContain('k8s.pod.name="checkout-7d9"');
     expect(relatedMetricsHref).toContain('host.name="node-a"');
     const relatedLogsCall = apiMessageGet.mock.calls.find(call => 
String(call[0]).startsWith('/logs/list'));
@@ -309,6 +320,7 @@ describe('TraceManagePage client loading', () => {
     expect(relatedMetrics?.textContent).toContain('Related metrics');
     expect(relatedMetrics?.textContent).toContain('container.cpu.usage');
     expect(relatedMetrics?.textContent).toContain('system.cpu.utilization');
+    expect(relatedMetrics?.textContent).toContain('http.server.duration');
     const metricAction = 
relatedMetrics?.querySelector('[data-trace-manage-drawer-related-metric-query="container.cpu.usage"]')
 as HTMLAnchorElement | null;
     
expect(metricAction?.getAttribute('href')).toContain('/ingestion/otlp/metrics?');
     
expect(metricAction?.getAttribute('href')).toContain('query=container.cpu.usage');
@@ -322,6 +334,10 @@ describe('TraceManagePage client loading', () => {
     
expect(metricActionParams.get('relatedMetricReason')).toBe('resource-filter');
     
expect(metricActionParams.get('relatedMetricMatchedLabels')).toBe('k8s_pod_name');
     
expect(metricActionParams.get('relatedMetricResourceMatch')).toBe('{"k8s_pod_name":"checkout-7d9"}');
+    const operationMetricAction = 
relatedMetrics?.querySelector('[data-trace-manage-drawer-related-metric-query="http.server.duration"]')
 as HTMLAnchorElement | null;
+    
expect(operationMetricAction?.getAttribute('data-trace-manage-drawer-related-metric-source')).toBe('operation');
+    
expect(operationMetricAction?.getAttribute('data-trace-manage-drawer-related-metric-reason')).toBe('operation-context');
+    expect(new URL(operationMetricAction?.getAttribute('href') || '', 
'http://localhost').searchParams.get('operationName')).toBe('POST /checkout');
   });
 
   it('narrows trace table rows by service from the row cell', async () => {
diff --git a/web-next/app/trace/manage/trace-manage-page.tsx 
b/web-next/app/trace/manage/trace-manage-page.tsx
index b8893f151e..ad5abb5d30 100644
--- a/web-next/app/trace/manage/trace-manage-page.tsx
+++ b/web-next/app/trace/manage/trace-manage-page.tsx
@@ -235,7 +235,8 @@ function buildTraceRelatedMetricsApiUrl(metricsHref: string 
| null | undefined)
     'entityName',
     'serviceName',
     'serviceNamespace',
-    'environment'
+    'environment',
+    'operationName'
   ].forEach(key => {
     const value = sourceParams.get(key)?.trim();
     if (value) params.set(key, value);
diff --git a/web-next/lib/log-manage/view-model.test.ts 
b/web-next/lib/log-manage/view-model.test.ts
index cb8cf65115..0c7b4747c1 100644
--- a/web-next/lib/log-manage/view-model.test.ts
+++ b/web-next/lib/log-manage/view-model.test.ts
@@ -722,6 +722,33 @@ describe('log view model', () => {
     expect(metricsParams.get('template')).toBe('hertzbeat-self');
   });
 
+  it('keeps selected log operation context in metrics handoffs', () => {
+    const result = buildLogHandoffLinks(
+      {
+        traceId: 'trace-operation',
+        spanId: 'span-operation',
+        timeUnixNano: 1_710_000_000_000_000_000,
+        resource: {
+          'service.name': 'checkout',
+          'service.namespace': 'payments'
+        },
+        attributes: {
+          'http.route': 'POST /checkout'
+        }
+      } as any,
+      {
+        entityId: '7',
+        entityType: 'service',
+        entityName: 'Checkout API'
+      }
+    );
+
+    const metricsParams = new URL(result.metricsHref, 
'https://example.com').searchParams;
+    expect(metricsParams.get('operationName')).toBe('POST /checkout');
+    expect(metricsParams.get('serviceName')).toBe('checkout');
+    expect(metricsParams.get('entityType')).toBe('service');
+  });
+
   it('can override trace and metrics return paths with the current log 
workspace route', () => {
     const currentLogReturnTo =
       
`/log/manage?traceId=trace-1&spanId=span-1&view=stream&start=1709999100000&end=1710000060000&returnTo=%2Foverview&returnLabel=${encodeURIComponent(t('menu.log.manage'))}`;
diff --git a/web-next/lib/log-manage/view-model.ts 
b/web-next/lib/log-manage/view-model.ts
index 048223db6a..d3c6949aaa 100644
--- a/web-next/lib/log-manage/view-model.ts
+++ b/web-next/lib/log-manage/view-model.ts
@@ -705,6 +705,12 @@ export function buildLogHandoffLinks(
   );
   const traceId = firstText(selectedLog?.traceId, routeContext.traceId);
   const spanId = firstText(selectedLog?.spanId, routeContext.spanId);
+  const operationName = firstText(
+    readAttribute(selectedLog?.attributes, 'operation.name'),
+    readAttribute(selectedLog?.attributes, 'span.name'),
+    readAttribute(selectedLog?.attributes, 'http.route'),
+    routeContext.operationName
+  );
   const metricsFilter = buildLogMetricsResourceFilter(selectedLog);
   const signalDraft = options?.alertDraft;
   const signalContext: SignalRouteContext = {
@@ -750,6 +756,7 @@ export function buildLogHandoffLinks(
   if (spanId) metricsParams.set('spanId', spanId);
   if (serviceName) metricsParams.set('serviceName', serviceName);
   if (serviceNamespace) metricsParams.set('serviceNamespace', 
serviceNamespace);
+  if (operationName) metricsParams.set('operationName', operationName);
   if (metricsFilter) metricsParams.set('filter', metricsFilter);
   appendSignalRouteContext(metricsParams, metricsContext);
 
diff --git a/web-next/lib/signal-route-context.ts 
b/web-next/lib/signal-route-context.ts
index d6fe9ce2b3..4f6b1f1ce5 100644
--- a/web-next/lib/signal-route-context.ts
+++ b/web-next/lib/signal-route-context.ts
@@ -32,6 +32,7 @@ export const SIGNAL_ROUTE_CONTEXT_PARAM_KEYS = [
   'monitorInstance',
   'traceId',
   'spanId',
+  'operationName',
   'source',
   'collector',
   'template',
diff --git a/web-next/lib/trace-manage/view-model.test.ts 
b/web-next/lib/trace-manage/view-model.test.ts
index f25462e8f4..436999721a 100644
--- a/web-next/lib/trace-manage/view-model.test.ts
+++ b/web-next/lib/trace-manage/view-model.test.ts
@@ -745,6 +745,7 @@ describe('trace view model', () => {
       } as any,
       {
         spanId: 'span-db',
+        spanName: 'POST /checkout',
         serviceName: 'checkout',
         resourceAttributes: {
           'deployment.environment.name': 'prod-east',
@@ -778,6 +779,7 @@ describe('trace view model', () => {
     expect(metricsParams.get('entityName')).toBe('Checkout DB');
     expect(metricsParams.get('collector')).toBe('collector-b');
     expect(metricsParams.get('template')).toBe('postgres');
+    expect(metricsParams.get('operationName')).toBe('POST /checkout');
 
     const entityHref = new URL(result.entityHref, 'https://example.com');
     expect(entityHref.pathname).toBe('/entities/43');
diff --git a/web-next/lib/trace-manage/view-model.ts 
b/web-next/lib/trace-manage/view-model.ts
index 9678d5f3c5..d78980e959 100644
--- a/web-next/lib/trace-manage/view-model.ts
+++ b/web-next/lib/trace-manage/view-model.ts
@@ -848,6 +848,7 @@ export function buildTraceHandoffLinks(
     routeContext.template,
     readTraceSignalAttribute(detail, selectedSpan, 'template', 
'hertzbeat.template', 'hertzbeat_template', 'hertzbeat.monitor_template', 
'hertzbeat_monitor_template')
   );
+  const operationName = firstText(selectedSpan?.spanName, 
detail?.rootSpanName, routeContext.operationName);
   const metricsFilter = buildTraceMetricsResourceFilter(detail, selectedSpan);
   const routeStart = readEpochMillisRouteParam(routeContext.start);
   const routeEnd = readEpochMillisRouteParam(routeContext.end);
@@ -898,6 +899,7 @@ export function buildTraceHandoffLinks(
   if (spanId) metricsParams.set('spanId', spanId);
   if (serviceName) metricsParams.set('serviceName', serviceName);
   if (serviceNamespace) metricsParams.set('serviceNamespace', 
serviceNamespace);
+  if (operationName) metricsParams.set('operationName', operationName);
   if (metricsFilter) metricsParams.set('filter', metricsFilter);
   appendSignalRouteContext(metricsParams, metricsContext);
 
diff --git a/web-next/lib/types.ts b/web-next/lib/types.ts
index bda55f6916..a04f05c94d 100644
--- a/web-next/lib/types.ts
+++ b/web-next/lib/types.ts
@@ -375,6 +375,7 @@ export interface OtlpMetricsConsole {
 export interface OtlpRelatedMetrics {
   context?: OtlpMetricsConsole['context'];
   filter?: string | null;
+  operationName?: string | null;
   source?: string | null;
   candidateCount?: number;
   resourceMatchers?: Array<{


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to