Repository: Kong/public-ui-components
Pull request #2017: fix(tracing): hide nodes for missing spans
Author: login: sumimakito
Created: 2025-03-12T13:23:05Z | Merged: 2025-03-13T10:05:29Z

# Summary

This pull request hides the nodes in the trace lifecycle view lacking the corresponding spans. Sandbox is updated to provide more controls to test out different combinations.

KM-1023


Diff:
diff --git a/packages/core/tracing/sandbox/fixtures/trace-batches-no-match-routes.json b/packages/core/tracing/sandbox/fixtures/trace-batches-no-match-routes.json
deleted file mode 100644
index 72eb75e3f1..0000000000
--- a/packages/core/tracing/sandbox/fixtures/trace-batches-no-match-routes.json
+++ /dev/null
@@ -1 +0,0 @@
-{"batches":[{"resource":{"attributes":[{"key":"service.instance.id","value":{"stringValue":"9f087ea6-8daf-4123-b92c-22acd0231dd8"}},{"key":"service.version","value":{"stringValue":"3.10.0.0"}},{"key":"session.id","value":{"stringValue":"01954121-316b-711c-95fc-af362d56218f"}},{"key":"service.name","value":{"stringValue":"kong"}}]},"scopeSpans":[{"scope":{"name":"kong-internal","version":"0.1.0"},"spans":[{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"q2ngb64dZPY=","parentSpanId":"FDw/YYuyrlk=","name":"kong.read_client_http_headers","kind":"SPAN_KIND_SERVER","startTimeUnixNano":"1740554462454999808","endTimeUnixNano":"1740554462455796992","attributes":[{"key":"proxy.kong.http.request.headers.size","value":{"doubleValue":152}},{"key":"proxy.kong.http.request.headers.count","value":{"doubleValue":6}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"FDw/YYuyrlk=","name":"kong","kind":"SPAN_KIND_SERVER","startTimeUnixNano":"1740554462455000064","endTimeUnixNano":"1740554462457276672","attributes":[{"key":"http.request.header.host","value":{"stringValue":"localhost"}},{"key":"http.response.size","value":{"stringValue":"381"}},{"key":"proxy.kong.request.id","value":{"stringValue":"d65be64a1426cc2446affd5f5a0b2910"}},{"key":"http.request.size","value":{"stringValue":"173"}},{"key":"http.request.method","value":{"stringValue":"GET"}},{"key":"url.scheme","value":{"stringValue":"http"}},{"key":"proxy.kong.latency.upstream","value":{"doubleValue":0}},{"key":"proxy.kong.latency.client","value":{"doubleValue":0.8394240000000001}},{"key":"network.protocol.version","value":{"stringValue":"1.1"}},{"key":"network.protocol.name","value":{"stringValue":"http"}},{"key":"server.port","value":{"stringValue":"8000"}},{"key":"server.address","value":{"stringValue":"10.88.0.15"}},{"key":"proxy.kong.latency.3p.http_client.total_io","value":{"doubleValue":0}},{"key":"proxy.kong.latency.3p.dns.total_io","value":{"doubleValue":0}},{"key":"proxy.kong.latency.3p.total_io","value":{"doubleValue":0}},{"key":"proxy.kong.client.keepalive","value":{"boolValue":false}},{"key":"client.port","value":{"stringValue":"55804"}},{"key":"http.response.status_code","value":{"doubleValue":404}},{"key":"client.address","value":{"stringValue":"10.88.0.15"}},{"key":"proxy.kong.latency.internal","value":{"doubleValue":1.1605759999999998}},{"key":"proxy.kong.http.request.body.size","value":{"doubleValue":0}},{"key":"http.route","value":{"stringValue":""}},{"key":"proxy.kong.http.response.body.size","value":{"stringValue":"103"}},{"key":"proxy.kong.latency.3p.tcpsock.total_io","value":{"doubleValue":0}},{"key":"proxy.kong.latency.3p.redis.total_io","value":{"doubleValue":0}},{"key":"proxy.kong.latency.total","value":{"doubleValue":2}},{"key":"url.full","value":{"stringValue":"http://localhost:8000/xxx"}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"zrZ2rI/FfSI=","parentSpanId":"FDw/YYuyrlk=","name":"kong.phase.rewrite","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462455815424","endTimeUnixNano":"1740554462455868416","status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"uwgXKPy1X3Y=","parentSpanId":"FDw/YYuyrlk=","name":"kong.phase.access","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462455887872","endTimeUnixNano":"1740554462456167680","status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"t4dxpfsj9bw=","parentSpanId":"uwgXKPy1X3Y=","name":"kong.router","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456067328","endTimeUnixNano":"1740554462456130816","attributes":[{"key":"proxy.kong.router.matched","value":{"boolValue":false}}],"events":[{"timeUnixNano":"1740554462456129536","name":"exception","attributes":[{"key":"exception.message","value":{"stringValue":"no Route matched with those values"}}]}],"status":{"code":"STATUS_CODE_ERROR"}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"tYj+lRNvSVE=","parentSpanId":"uwgXKPy1X3Y=","name":"kong.read_client_http_body","kind":"SPAN_KIND_SERVER","startTimeUnixNano":"1740554462456137728","endTimeUnixNano":"1740554462456147200","attributes":[{"key":"proxy.kong.http.content_length","value":{"doubleValue":0}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"92+a90kcTyo=","parentSpanId":"FDw/YYuyrlk=","name":"kong.phase.header_filter","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456531968","endTimeUnixNano":"1740554462456837888","status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"f7YSenbtypY=","parentSpanId":"92+a90kcTyo=","name":"kong.header_filter.plugin.response-ratelimiting","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456758784","endTimeUnixNano":"1740554462456786944","attributes":[{"key":"proxy.kong.plugin.id","value":{"stringValue":"5a6798a6-d3d9-405d-bb20-091884a78ba8"}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"dfyN9uS9r/A=","parentSpanId":"92+a90kcTyo=","name":"kong.header_filter.plugin.response-transformer","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456807680","endTimeUnixNano":"1740554462456820992","attributes":[{"key":"proxy.kong.plugin.id","value":{"stringValue":"41994551-eac9-4c3a-8f8a-ff2cac1c1dc0"}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"+NehwLE6K5Q=","parentSpanId":"FDw/YYuyrlk=","name":"kong.phase.body_filter","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456858368","endTimeUnixNano":"1740554462457210112","status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"WpWyLBTh5qo=","parentSpanId":"+NehwLE6K5Q=","name":"kong.body_filter.plugin.response-transformer","kind":"SPAN_KIND_INTERNAL","startTimeUnixNano":"1740554462456887040","endTimeUnixNano":"1740554462457205504","attributes":[{"key":"chunks","value":{"arrayValue":{"values":[{"kvlistValue":{"values":[{"key":"chunk_n","value":{"doubleValue":1}},{"key":"execution_time","value":{"doubleValue":35328}}]}},{"kvlistValue":{"values":[{"key":"chunk_n","value":{"doubleValue":2}},{"key":"execution_time","value":{"doubleValue":315904}}]}}]}}},{"key":"num_chunks","value":{"doubleValue":2}},{"key":"proxy.kong.plugin.id","value":{"stringValue":"41994551-eac9-4c3a-8f8a-ff2cac1c1dc0"}}],"status":{}},{"traceId":"dezaTzD6ZzN3Q1/7QVZD7g==","spanId":"xvpcdpcrFT0=","parentSpanId":"FDw/YYuyrlk=","name":"kong.wait_for_client_read","kind":"SPAN_KIND_SERVER","startTimeUnixNano":"1740554462457210112","endTimeUnixNano":"1740554462457242880","status":{}}]}]}]}
diff --git a/packages/core/tracing/sandbox/pages/TraceViewPage.vue b/packages/core/tracing/sandbox/pages/TraceViewPage.vue
index 3239780e9c..ddaebe0b37 100644
--- a/packages/core/tracing/sandbox/pages/TraceViewPage.vue
+++ b/packages/core/tracing/sandbox/pages/TraceViewPage.vue
@@ -16,24 +16,7 @@
       Click the close button to dismiss the slideout (not a bug)<br>
     </div>
 
-    <KSelect
-      v-model="selectedTraceIndex"
-      class="trace-select"
-      :items="[
-        {
-          label: 'Normal',
-          value: 0,
-        },{
-          label: 'No matched routes',
-          value: 1,
-        }
-      ]"
-      label="Trace example"
-    />
-
-
     <KSlideout
-      :key="selectedTraceIndex"
       class="trace-viewer-slideout"
       :close-on-blur="false"
       :has-overlay="false"
@@ -67,6 +50,7 @@
       >
         <template #summary>
           <SummaryViewTab
+            :key="renderKey"
             :payloads="enablePayloads ? payloads : undefined"
             :root-span="spanTrees.roots[0]"
             :show-skeleton="showSkeleton"
@@ -87,11 +71,43 @@
       class="controls"
       title="Controls"
     >
+      <KInputSwitch
+        v-model="hasRequests"
+        label="Requests"
+      />
+
+      <KInputSwitch
+        v-model="hasResponses"
+        label="Responses"
+      />
+
+      <KInputSwitch
+        v-model="hasClientIn"
+        label="Client In"
+      />
+
+      <KInputSwitch
+        v-model="hasClientOut"
+        label="Client Out"
+      />
+
+      <KInputSwitch
+        v-model="hasUpstreamIn"
+        label="Upstream In"
+      />
+
+      <KInputSwitch
+        v-model="hasUpstreamOut"
+        label="Upstream Out"
+      />
+
+      <hr>
+
       <KInputSwitch
         v-model="showSkeleton"
         label="Skeleton"
       />
-      <br>
+
       <KInputSwitch
         v-model="enablePayloads"
         label="Payloads"
@@ -101,23 +117,74 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
-import type { Body, Headers , TraceBatches } from '../../src'
-import { buildSpanTrees, mergeSpansInTraceBatches, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
-// import rawSpans from '../fixtures/spans.json'
+import { computed, ref, watch } from 'vue'
+import type { Body, Headers, SpanNode } from '../../src'
+import { buildSpanTrees, getPhaseAndPlugin, KONG_PHASES, mergeSpansInTraceBatches, SPAN_NAMES, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
 import traceBatches from '../fixtures/trace-batches.json'
-import traceBatchesNoMatchRoutes from '../fixtures/trace-batches-no-match-routes.json'
 
-const selectableTraces = [traceBatches, traceBatchesNoMatchRoutes]
-const selectedTraceIndex = ref(0)
+const renderKey = ref(0)
+
+const hasRequests = ref(true)
+const hasResponses = ref(true)
+
+const hasClientIn = ref(true)
+const hasClientOut = ref(true)
+
+const hasUpstreamIn = ref(true)
+const hasUpstreamOut = ref(true)
+
+const trimTree = (tree: SpanNode) => {
+  tree.children = tree.children.filter((node) => {
+    const pluginSpan = getPhaseAndPlugin(node.span.name)
+    if (pluginSpan) {
+      switch (pluginSpan.phase) {
+        case KONG_PHASES.CERTIFICATE:
+        case KONG_PHASES.REWRITE:
+        case KONG_PHASES.ACCESS:
+          return hasRequests.value
+        case KONG_PHASES.RESPONSE:
+        case KONG_PHASES.HEADER_FILTER:
+        case KONG_PHASES.BODY_FILTER:
+          return hasResponses.value
+        default:
+          return true
+      }
+    }
+
+    switch (node.span.name) {
+      case SPAN_NAMES.CLIENT_HEADERS:
+      case SPAN_NAMES.READ_BODY:
+        return hasClientOut.value
+      case SPAN_NAMES.FLUSH_TO_DOWNSTREAM:
+        return hasClientIn.value
+      case SPAN_NAMES.KONG_UPSTREAM_SELECTION:
+      case SPAN_NAMES.KONG_SEND_REQUEST_TO_UPSTREAM:
+        return hasUpstreamIn.value
+      case SPAN_NAMES.KONG_READ_HEADERS_FROM_UPSTREAM:
+      case SPAN_NAMES.KONG_READ_BODY_FROM_UPSTREAM:
+        return hasUpstreamOut.value
+      default:
+        return true
+    }
+  })
+  tree.children.forEach(trimTree)
+}
+
+const spanTrees = computed(() => {
+  const trees = buildSpanTrees(mergeSpansInTraceBatches(traceBatches))
+  trimTree(trees.roots[0])
+  return trees
+})
+
+watch(spanTrees, () => {
+  renderKey.value += 1
+}, { deep:true })
 
 const controlPlaneId = import.meta.env.VITE_KONNECT_CONTROL_PLANE_ID || ''
 
-// const spanRoots = computed(() => buildSpanTrees(rawSpans))
-const spanTrees = computed(() => buildSpanTrees(mergeSpansInTraceBatches(selectableTraces[selectedTraceIndex.value])))
 const showSkeleton = ref(false)
 const enablePayloads = ref(true)
-const slideoutVisible = ref(false)
+const slideoutVisible = ref(true)
 const tabs = [
   { hash: '#summary', title: 'Summary' },
   { hash: '#trace', title: 'Trace' },
@@ -281,5 +348,13 @@ const payloads = {
   margin-bottom: 16px;
   position: fixed;
   width: auto;
+
+  :deep(.card-content) {
+    align-items: flex-start;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    justify-content: flex-start;
+  }
 }
 </style>
diff --git a/packages/core/tracing/src/components/lifecycle/LifecycleView.vue b/packages/core/tracing/src/components/lifecycle/LifecycleView.vue
index b44630f7a4..5277dc0426 100644
--- a/packages/core/tracing/src/components/lifecycle/LifecycleView.vue
+++ b/packages/core/tracing/src/components/lifecycle/LifecycleView.vue
@@ -212,14 +212,6 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
 
   if (!clientNode) {
     throw new Error('Missing the client node')
-  } else if (!clientOutNode) {
-    throw new Error('Missing the client out node')
-  } else if (!clientInNode) {
-    throw new Error('Missing the client in node')
-  }
-
-  if ((upstreamInNode || upstreamOutNode) && !upstreamNode) {
-    throw new Error('Missing the upstream node while upstream in/out node(s) is/are present')
   }
 
   // >>> Layout - Request Group
@@ -272,9 +264,13 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
   canvasX += clientNode.dimensions.width + CANVAS_COLUMN_GAP
 
   // Client In / Client Out (They are horizontally center-aligned)
-  const clientInOutMaxWidth = Math.max(clientOutNode.dimensions.width, clientInNode.dimensions.width)
-  clientOutNode.position.x = canvasX + (clientInOutMaxWidth - clientOutNode.dimensions.width) / 2
-  clientInNode.position.x = canvasX + (clientInOutMaxWidth - clientInNode.dimensions.width) / 2
+  const clientInOutMaxWidth = Math.max(clientOutNode?.dimensions.width ?? 0, clientInNode?.dimensions.width ?? 0)
+  if (clientOutNode) {
+    clientOutNode.position.x = canvasX + (clientInOutMaxWidth - clientOutNode.dimensions.width) / 2
+  }
+  if (clientInNode) {
+    clientInNode.position.x = canvasX + (clientInOutMaxWidth - clientInNode.dimensions.width) / 2
+  }
   canvasX += clientInOutMaxWidth + CANVAS_COLUMN_GAP
 
   // Request Group / Response Group (They are horizontally center-aligned)
@@ -287,17 +283,17 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
   }
   canvasX += requestsResponsesMaxWidth + CANVAS_COLUMN_GAP
 
-  if (upstreamNode) {
-    const upstreamInOutMaxWidth = Math.max(upstreamInNode?.dimensions.width ?? 0, upstreamOutNode?.dimensions.width ?? 0)
-    if (upstreamInNode) {
-      upstreamInNode.position.x = canvasX + (upstreamInOutMaxWidth - upstreamInNode.dimensions.width) / 2
-    }
-    if (upstreamOutNode) {
-      upstreamOutNode.position.x = canvasX + (upstreamInOutMaxWidth - upstreamOutNode.dimensions.width) / 2
-    }
-    canvasX += upstreamInOutMaxWidth + CANVAS_COLUMN_GAP
+  const upstreamInOutMaxWidth = Math.max(upstreamInNode?.dimensions.width ?? 0, upstreamOutNode?.dimensions.width ?? 0)
+  if (upstreamInNode) {
+    upstreamInNode.position.x = canvasX + (upstreamInOutMaxWidth - upstreamInNode.dimensions.width) / 2
+  }
+  if (upstreamOutNode) {
+    upstreamOutNode.position.x = canvasX + (upstreamInOutMaxWidth - upstreamOutNode.dimensions.width) / 2
+  }
+  canvasX += upstreamInOutMaxWidth + CANVAS_COLUMN_GAP
 
-    // Upstream
+  // Upstream
+  if (upstreamNode) {
     upstreamNode.position.x = canvasX
   }
   // <<< Layout along X-axis
@@ -319,8 +315,10 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
   //   upstreamInNode.position.y + upstreamInNode.dimensions.height,
   // ) + CANVAS_ROW_GAP
   // If align with the request group itself:
-  const upperInnerMaxHeight = Math.max(clientOutNode.dimensions.height, requestGroupMetrics.height, upstreamInNode?.dimensions.height ?? 0)
-  clientOutNode.position.y = layoutY + (upperInnerMaxHeight - clientOutNode.dimensions.height) / 2
+  const upperInnerMaxHeight = Math.max(clientOutNode?.dimensions.height ?? 0, requestGroupMetrics.height, upstreamInNode?.dimensions.height ?? 0)
+  if (clientOutNode) {
+    clientOutNode.position.y = layoutY + (upperInnerMaxHeight - clientOutNode.dimensions.height) / 2
+  }
   if (requestGroupNode) {
     requestGroupNode.position.y = layoutY + (upperInnerMaxHeight - requestGroupMetrics.height) / 2
   }
@@ -328,7 +326,8 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
     upstreamInNode.position.y = layoutY + (upperInnerMaxHeight - upstreamInNode.dimensions.height) / 2
   }
   layoutY = Math.max(
-    clientOutNode.position.y + clientOutNode.dimensions.height,
+    layoutY,
+    clientOutNode ? (clientOutNode.position.y + clientOutNode.dimensions.height) : 0,
     requestGroupNode ? requestGroupNode.position.y + requestGroupMetrics.height : 0,
     (upstreamInNode?.position.y ?? 0) + (upstreamInNode?.dimensions.height ?? 0),
   ) + CANVAS_ROW_GAP
@@ -350,8 +349,10 @@ const layout = (nodes: LifecycleNode[]): LifecycleNode[] => {
   // }
   // upstreamOutNode.position.y = layoutY + responseGroupMetrics.innerOffsetY + (lowerInnerMaxHeight - upstreamOutNode.dimensions.height) / 2
   // If align with the response group itself:
-  const lowerInnerMaxHeight = Math.max(clientInNode.dimensions.height, responseGroupMetrics.height, upstreamOutNode?.dimensions.height ?? 0)
-  clientInNode.position.y = layoutY + (lowerInnerMaxHeight - clientInNode.dimensions.height) / 2
+  const lowerInnerMaxHeight = Math.max(clientInNode?.dimensions.height ?? 0, responseGroupMetrics.height, upstreamOutNode?.dimensions.height ?? 0)
+  if (clientInNode) {
+    clientInNode.position.y = layoutY + (lowerInnerMaxHeight - clientInNode.dimensions.height) / 2
+  }
   if (responseGroupNode) {
     responseGroupNode.position.y = layoutY + (lowerInnerMaxHeight - responseGroupMetrics.height) / 2
   }
diff --git a/packages/core/tracing/src/types/lifecycle.ts b/packages/core/tracing/src/types/lifecycle.ts
index 6039bcaa0f..14b79afbe1 100644
--- a/packages/core/tracing/src/types/lifecycle.ts
+++ b/packages/core/tracing/src/types/lifecycle.ts
@@ -6,8 +6,8 @@ import type { SpanNode } from './spans'
 export interface LifecycleGraphNodeTree {
   client: {
     node: LifecycleNode
-    in: LifecycleNode
-    out: LifecycleNode
+    in?: LifecycleNode
+    out?: LifecycleNode
   }
   requests?: {
     node: LifecycleNode
diff --git a/packages/core/tracing/src/utils/lifecycle.ts b/packages/core/tracing/src/utils/lifecycle.ts
index 36dcb46342..4b622df584 100644
--- a/packages/core/tracing/src/utils/lifecycle.ts
+++ b/packages/core/tracing/src/utils/lifecycle.ts
@@ -153,25 +153,29 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
           },
           zIndex: 2,
         },
-        out: {
-          id: LifecycleNodeType.CLIENT_OUT,
-          position: { x: 0, y: 0 },
-          data: {
-            type: LifecycleNodeType.CLIENT_OUT,
-            durationNano: clientOutSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
-            durationTooltipKey: 'lifecycle.client_out.tooltip',
+        ...clientOutSpans.length > 0 && {
+          out:  {
+            id: LifecycleNodeType.CLIENT_OUT,
+            position: { x: 0, y: 0 },
+            data: {
+              type: LifecycleNodeType.CLIENT_OUT,
+              durationNano: clientOutSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
+              durationTooltipKey: 'lifecycle.client_out.tooltip',
+            },
+            zIndex: 10, // Because we may want to show tooltips on this node
           },
-          zIndex: 10, // Because we may want to show tooltips on this node
         },
-        in: {
-          id: LifecycleNodeType.CLIENT_IN,
-          position: { x: 0, y: 0 },
-          data: {
-            type: LifecycleNodeType.CLIENT_IN,
-            durationNano: clientInSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0) + pluginDurationExcluded,
-            durationTooltipKey: 'lifecycle.client_in.tooltip',
+        ...clientInSpans.length > 0 && {
+          in: {
+            id: LifecycleNodeType.CLIENT_IN,
+            position: { x: 0, y: 0 },
+            data: {
+              type: LifecycleNodeType.CLIENT_IN,
+              durationNano: clientInSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0) + pluginDurationExcluded,
+              durationTooltipKey: 'lifecycle.client_in.tooltip',
+            },
+            zIndex: 10, // Because we may want to show tooltips on this node
           },
-          zIndex: 10, // Because we may want to show tooltips on this node
         },
       },
       requests: {
@@ -206,25 +210,29 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
             },
             zIndex: 1,
           },
-          in: {
-            id: LifecycleNodeType.UPSTREAM_IN,
-            position: { x: 0, y: 0 },
-            data: {
-              type: LifecycleNodeType.UPSTREAM_IN,
-              durationNano: upstreamInSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
-              durationTooltipKey: 'lifecycle.upstream_in.tooltip',
+          ...upstreamInSpans.length > 0 && {
+            in: {
+              id: LifecycleNodeType.UPSTREAM_IN,
+              position: { x: 0, y: 0 },
+              data: {
+                type: LifecycleNodeType.UPSTREAM_IN,
+                durationNano: upstreamInSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
+                durationTooltipKey: 'lifecycle.upstream_in.tooltip',
+              },
+              zIndex: 10, // Because we may want to show tooltips on this node
             },
-            zIndex: 10, // Because we may want to show tooltips on this node
           },
-          out: {
-            id: LifecycleNodeType.UPSTREAM_OUT,
-            position: { x: 0, y: 0 },
-            data: {
-              type: LifecycleNodeType.UPSTREAM_OUT,
-              durationNano: upstreamOutSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
-              durationTooltipKey: 'lifecycle.upstream_out.tooltip',
+          ...upstreamOutSpans.length > 0 && {
+            out: {
+              id: LifecycleNodeType.UPSTREAM_OUT,
+              position: { x: 0, y: 0 },
+              data: {
+                type: LifecycleNodeType.UPSTREAM_OUT,
+                durationNano: upstreamOutSpans.reduce((duration, span) => duration + (span.durationNano ?? 0), 0),
+                durationTooltipKey: 'lifecycle.upstream_out.tooltip',
+              },
+              zIndex: 10, // Because we may want to show tooltips on this node
             },
-            zIndex: 10, // Because we may want to show tooltips on this node
           },
         },
       },
@@ -253,16 +261,18 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
     edges: [],
   }
 
-  const clientNodes = graph.nodeTree.client
+  let previousNode = graph.nodeTree.client.node
 
-  graph.edges.push({
-    id: `${clientNodes.node.id}->${clientNodes.out.id}`,
-    source: clientNodes.node.id,
-    target: clientNodes.out.id,
-    type: 'seamless-smoothstep',
-    zIndex: 1,
-  })
-  let previousNode = graph.nodeTree.client.out
+  if (graph.nodeTree.client.out) {
+    graph.edges.push({
+      id: `${previousNode.id}->${graph.nodeTree.client.out.id}`,
+      source: previousNode.id,
+      target: graph.nodeTree.client.out.id,
+      type: 'seamless-smoothstep',
+      zIndex: 1,
+    })
+    previousNode = graph.nodeTree.client.out
+  }
 
   if (graph.nodeTree.requests) {
     const children = graph.nodeTree.requests.children
@@ -301,15 +311,17 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
       previousNode = graph.nodeTree.upstream.in
     }
 
-    graph.edges.push({
-      id: `${previousNode.id}->${graph.nodeTree.upstream.node.id}`,
-      source: previousNode.id,
-      target: graph.nodeTree.upstream.node.id,
-      type: 'seamless-smoothstep',
-      markerEnd: MarkerType.ArrowClosed,
-      zIndex: 2,
-    })
-    previousNode = graph.nodeTree.upstream.node
+    if (graph.nodeTree.upstream.node) {
+      graph.edges.push({
+        id: `${previousNode.id}->${graph.nodeTree.upstream.node.id}`,
+        source: previousNode.id,
+        target: graph.nodeTree.upstream.node.id,
+        type: 'seamless-smoothstep',
+        markerEnd: MarkerType.ArrowClosed,
+        zIndex: 2,
+      })
+      previousNode = graph.nodeTree.upstream.node
+    }
 
     if (graph.nodeTree.upstream.out) {
       graph.edges.push({
@@ -348,30 +360,32 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
     previousNode = graph.nodeTree.responses.node
   }
 
-  graph.edges.push(
-    {
-      id: `${previousNode.id}->${clientNodes.in.id}`,
+  if (graph.nodeTree.client.in) {
+    graph.edges.push({
+      id: `${previousNode.id}->${graph.nodeTree.client.in.id}`,
       source: previousNode.id,
-      target: clientNodes.node.id,
+      target: graph.nodeTree.client.in.id,
       type: 'seamless-smoothstep',
       markerEnd: MarkerType.ArrowClosed,
       zIndex: 1,
-    },
-    {
-      id: `${clientNodes.in.id}->${clientNodes.node.id}`,
-      source: clientNodes.in.id,
-      target: clientNodes.node.id,
-      type: 'seamless-smoothstep',
-      markerEnd: MarkerType.ArrowClosed,
-      zIndex: 1,
-    },
-  )
+    })
+    previousNode = graph.nodeTree.client.in
+  }
+
+  graph.edges.push({
+    id: `${previousNode.id}->${graph.nodeTree.client.node.id}`,
+    source: previousNode.id,
+    target: graph.nodeTree.client.node.id,
+    type: 'seamless-smoothstep',
+    markerEnd: MarkerType.ArrowClosed,
+    zIndex: 1,
+  })
 
   return {
     ...graph,
     nodes: [
-      clientNodes.node,
-      clientNodes.out,
+      graph.nodeTree.client.node,
+      ...graph.nodeTree.client.out ? [graph.nodeTree.client.out] : [],
       ...graph.nodeTree.requests ? [
         graph.nodeTree.requests.node,
         ...graph.nodeTree.requests.children,
@@ -385,7 +399,7 @@ export const buildLifecycleGraph = (root: SpanNode): LifecycleGraph => {
         graph.nodeTree.responses.node,
         ...graph.nodeTree.responses.children,
       ] : [],
-      clientNodes.in,
+      ...graph.nodeTree.client.in ? [graph.nodeTree.client.in] : [],
     ],
   }
 }


Reviews:
user: login: jw-foss
body: @sumimakito I have one question and left as comment, the rest of the code LGTM, feel free to ignore it :bow:
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:28:01Z
user: login: jw-foss
body: LGTM, thank you!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:53:01Z
user: login: Leopoldthecoder
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T09:53:57Z
user: login: sumimakito
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T09:55:52Z
user: login: sumimakito
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T03:46:16Z

Review comments:
diff_hunk: @@ -101,23 +124,77 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
-import type { Body, Headers , TraceBatches } from '../../src'
-import { buildSpanTrees, mergeSpansInTraceBatches, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
-// import rawSpans from '../fixtures/spans.json'
+import { computed, ref, watch } from 'vue'
+import type { Body, Headers, SpanNode } from '../../src'
+import { buildSpanTrees, getPhaseAndPlugin, KONG_PHASES, mergeSpansInTraceBatches, SPAN_NAMES, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
 import traceBatches from '../fixtures/trace-batches.json'
-import traceBatchesNoMatchRoutes from '../fixtures/trace-batches-no-match-routes.json'
 
-const selectableTraces = [traceBatches, traceBatchesNoMatchRoutes]
-const selectedTraceIndex = ref(0)
+const renderKey = ref(0)
+
+const hasRequests = ref(true)
+const hasResponses = ref(true)
+
+const hasClientIn = ref(true)
+const hasClientOut = ref(true)
+
+const hasUpstream = ref(true)
+const hasUpstreamIn = ref(true)
+const hasUpstreamOut = ref(true)
+
+const trimTree = (tree: SpanNode) => {
+  tree.children = tree.children.filter((node) => {
+    const pluginSpan = getPhaseAndPlugin(node.span.name)
+    if (pluginSpan) {
+      switch (pluginSpan.phase) {
+        case KONG_PHASES.CERTIFICATE:
+        case KONG_PHASES.REWRITE:
+        case KONG_PHASES.ACCESS:
+          return hasRequests.value
+        case KONG_PHASES.RESPONSE:
+        case KONG_PHASES.HEADER_FILTER:
+        case KONG_PHASES.BODY_FILTER:
+          return hasResponses.value
+        default:
+          return true
+      }
+    }
+
+    switch (node.span.name) {
+      case SPAN_NAMES.CLIENT_HEADERS:
+      case SPAN_NAMES.READ_BODY:
+        return hasClientOut.value
+      case SPAN_NAMES.FLUSH_TO_DOWNSTREAM:
+        return hasClientIn.value
+      case SPAN_NAMES.KONG_UPSTREAM_SELECTION:
+      case SPAN_NAMES.KONG_SEND_REQUEST_TO_UPSTREAM:
+        return hasUpstream.value && hasUpstreamIn.value
+      case SPAN_NAMES.KONG_READ_HEADERS_FROM_UPSTREAM:
+      case SPAN_NAMES.KONG_READ_BODY_FROM_UPSTREAM:
+        return hasUpstream.value && hasUpstreamOut.value
+      case SPAN_NAMES.FIND_UPSTREAM:
+        return hasUpstream.value
+      default:
+        return true
+    }
+  })
+  tree.children.forEach(trimTree)
+}
+
+const spanTrees = computed(() => {
+  const trees = buildSpanTrees(mergeSpansInTraceBatches(traceBatches))
+  trimTree(trees.roots[0])
+  return trees
+})
+
+watch(spanTrees, () => {
+  renderKey.value += 1
+}, { deep:true })
path: packages/core/tracing/sandbox/pages/TraceViewPage.vue
user: login: jw-foss
body: **Question:** I see this is updated whenever the `spanTrees` gets changed, but this key is applied to `KSlideout` instead of its children, is this expected to re-render the entire `slideout` instead of its children?
created_at: 2025-03-13T09:27:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,23 +124,77 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
-import type { Body, Headers , TraceBatches } from '../../src'
-import { buildSpanTrees, mergeSpansInTraceBatches, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
-// import rawSpans from '../fixtures/spans.json'
+import { computed, ref, watch } from 'vue'
+import type { Body, Headers, SpanNode } from '../../src'
+import { buildSpanTrees, getPhaseAndPlugin, KONG_PHASES, mergeSpansInTraceBatches, SPAN_NAMES, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
 import traceBatches from '../fixtures/trace-batches.json'
-import traceBatchesNoMatchRoutes from '../fixtures/trace-batches-no-match-routes.json'
 
-const selectableTraces = [traceBatches, traceBatchesNoMatchRoutes]
-const selectedTraceIndex = ref(0)
+const renderKey = ref(0)
+
+const hasRequests = ref(true)
+const hasResponses = ref(true)
+
+const hasClientIn = ref(true)
+const hasClientOut = ref(true)
+
+const hasUpstream = ref(true)
+const hasUpstreamIn = ref(true)
+const hasUpstreamOut = ref(true)
+
+const trimTree = (tree: SpanNode) => {
+  tree.children = tree.children.filter((node) => {
+    const pluginSpan = getPhaseAndPlugin(node.span.name)
+    if (pluginSpan) {
+      switch (pluginSpan.phase) {
+        case KONG_PHASES.CERTIFICATE:
+        case KONG_PHASES.REWRITE:
+        case KONG_PHASES.ACCESS:
+          return hasRequests.value
+        case KONG_PHASES.RESPONSE:
+        case KONG_PHASES.HEADER_FILTER:
+        case KONG_PHASES.BODY_FILTER:
+          return hasResponses.value
+        default:
+          return true
+      }
+    }
+
+    switch (node.span.name) {
+      case SPAN_NAMES.CLIENT_HEADERS:
+      case SPAN_NAMES.READ_BODY:
+        return hasClientOut.value
+      case SPAN_NAMES.FLUSH_TO_DOWNSTREAM:
+        return hasClientIn.value
+      case SPAN_NAMES.KONG_UPSTREAM_SELECTION:
+      case SPAN_NAMES.KONG_SEND_REQUEST_TO_UPSTREAM:
+        return hasUpstream.value && hasUpstreamIn.value
+      case SPAN_NAMES.KONG_READ_HEADERS_FROM_UPSTREAM:
+      case SPAN_NAMES.KONG_READ_BODY_FROM_UPSTREAM:
+        return hasUpstream.value && hasUpstreamOut.value
+      case SPAN_NAMES.FIND_UPSTREAM:
+        return hasUpstream.value
+      default:
+        return true
+    }
+  })
+  tree.children.forEach(trimTree)
+}
+
+const spanTrees = computed(() => {
+  const trees = buildSpanTrees(mergeSpansInTraceBatches(traceBatches))
+  trimTree(trees.roots[0])
+  return trees
+})
+
+watch(spanTrees, () => {
+  renderKey.value += 1
+}, { deep:true })
path: packages/core/tracing/sandbox/pages/TraceViewPage.vue
user: login: sumimakito
body: Nice catch. This is intended as of today since we did not find a good way to reload and re-layout the lifecycle view after the span tree changes. It is only affecting the sandbox but not the production as we won't update the tree there. We will revisit and improve in the future.
created_at: 2025-03-13T09:55:52Z
author_association: MEMBER
diff_hunk: @@ -101,23 +124,77 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
-import type { Body, Headers , TraceBatches } from '../../src'
-import { buildSpanTrees, mergeSpansInTraceBatches, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
-// import rawSpans from '../fixtures/spans.json'
+import { computed, ref, watch } from 'vue'
+import type { Body, Headers, SpanNode } from '../../src'
+import { buildSpanTrees, getPhaseAndPlugin, KONG_PHASES, mergeSpansInTraceBatches, SPAN_NAMES, SummaryViewTab, TraceViewTab, type TraceViewerConfig } from '../../src'
 import traceBatches from '../fixtures/trace-batches.json'
-import traceBatchesNoMatchRoutes from '../fixtures/trace-batches-no-match-routes.json'
 
-const selectableTraces = [traceBatches, traceBatchesNoMatchRoutes]
-const selectedTraceIndex = ref(0)
+const renderKey = ref(0)
+
+const hasRequests = ref(true)
+const hasResponses = ref(true)
+
+const hasClientIn = ref(true)
+const hasClientOut = ref(true)
+
+const hasUpstream = ref(true)
+const hasUpstreamIn = ref(true)
+const hasUpstreamOut = ref(true)
+
+const trimTree = (tree: SpanNode) => {
+  tree.children = tree.children.filter((node) => {
+    const pluginSpan = getPhaseAndPlugin(node.span.name)
+    if (pluginSpan) {
+      switch (pluginSpan.phase) {
+        case KONG_PHASES.CERTIFICATE:
+        case KONG_PHASES.REWRITE:
+        case KONG_PHASES.ACCESS:
+          return hasRequests.value
+        case KONG_PHASES.RESPONSE:
+        case KONG_PHASES.HEADER_FILTER:
+        case KONG_PHASES.BODY_FILTER:
+          return hasResponses.value
+        default:
+          return true
+      }
+    }
+
+    switch (node.span.name) {
+      case SPAN_NAMES.CLIENT_HEADERS:
+      case SPAN_NAMES.READ_BODY:
+        return hasClientOut.value
+      case SPAN_NAMES.FLUSH_TO_DOWNSTREAM:
+        return hasClientIn.value
+      case SPAN_NAMES.KONG_UPSTREAM_SELECTION:
+      case SPAN_NAMES.KONG_SEND_REQUEST_TO_UPSTREAM:
+        return hasUpstream.value && hasUpstreamIn.value
+      case SPAN_NAMES.KONG_READ_HEADERS_FROM_UPSTREAM:
+      case SPAN_NAMES.KONG_READ_BODY_FROM_UPSTREAM:
+        return hasUpstream.value && hasUpstreamOut.value
+      case SPAN_NAMES.FIND_UPSTREAM:
+        return hasUpstream.value
+      default:
+        return true
+    }
+  })
+  tree.children.forEach(trimTree)
+}
+
+const spanTrees = computed(() => {
+  const trees = buildSpanTrees(mergeSpansInTraceBatches(traceBatches))
+  trimTree(trees.roots[0])
+  return trees
+})
+
+watch(spanTrees, () => {
+  renderKey.value += 1
+}, { deep:true })
path: packages/core/tracing/sandbox/pages/TraceViewPage.vue
user: login: sumimakito
body: @jw-foss I opened another PR https://github.com/Kong/public-ui-components/pull/2021 to improve this. Could you take a look at it?
created_at: 2025-03-14T03:46:16Z
author_association: MEMBER

PR comments: