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

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 24cd88b5a87e04fe95a267d6cade5737b608d72c
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 20:04:29 2026 +0800

    feat(ai): trace profiling shows the span waterfall beside the flame
    
    Completes the profiling result rendering from the original ask ("frame,
    trace+profiling, etc."): for a trace profile, analyze_profiling now captures
    the slowest profiled segment's full spans and the block renders the trace's
    span waterfall (NativeTraceWaterfall, profiled spans marked) above the 
flame —
    the whole right panel of the Profiling tab, the trace+profiling combination.
    Frozen + replay-badged like every captured block; the other flavors render 
the
    flame alone.
    
    analyzeTrace carries a traceContext { traceId, spans }; the segments query
    selects the full ProfileSpan fields the waterfall needs; ProfilingResultSpec
    gains the optional traceContext (BFF+UI lockstep).
    
    Validated live: trace analyze on agent::songs captured 139 flame frames + a
    19-span profiled trace (18 profiled) for the waterfall.
---
 CHANGELOG.md                            |  2 +-
 apps/bff/src/ai/skill/triggers/tools.ts |  1 +
 apps/bff/src/ai/types.ts                |  5 ++++-
 apps/bff/src/logic/oap/profiling.ts     | 25 +++++++++++++++++++---
 apps/ui/src/ai/ChatProfilingBlock.vue   | 37 ++++++++++++++++++++++++++++++---
 apps/ui/src/ai/types.ts                 |  4 ++++
 apps/ui/src/i18n/locales/en.json        |  2 ++
 7 files changed, 68 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 347ebe2..b4adf7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 - **Read-only, and it inherits your permissions.** The assistant can list 
layers/services, read active **alarms** (the "what's unhealthy" signal), browse 
the per-layer **metric catalog** (the curated MQE for each metric), drill a 
service down to its instances/endpoints, and render those metrics — every data 
tool checks the same read verb you already hold, so the assistant never sees 
more than you can, and it never changes configuration, rules, or dashboards.
 - **It renders the real feature views inline — same components, focused on the 
service.** Rather than a link you open, the assistant mounts the *actual* 
product views inside the chat, read-only and scoped to the service: ask for 
**topology** and it embeds the real service map focused one hop (hex nodes, 
edges, RPM/latency, zoom controls); ask how a service maps across layers and it 
shows the real **Smartscape hierarchy** fan; ask for **traces** and it embeds 
the Traces view — the trace l [...]
 - **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the 
assistant pulls a pod container's on-demand logs (the error stack) and shows 
the fetched lines inline as a read-only result — the same on-demand-log path as 
the Pod Logs tab, so nothing is stored and it inherits your `logs:read` 
permission. It's a result, not a console: no tail or refresh controls (operate 
a live tail in the Pod Logs tab); when a content filter was applied the block 
shows it, so an empty resul [...]
-- **It can propose profiling — the right kind for the target — and only you 
start it.** When metrics and traces can't localise a cause, the assistant 
presents a **decision card** explaining what it found, why profiling would 
help, and what it expects to reveal; nothing runs until you **approve it in the 
popout**, and only if you hold the profiling permission. It picks the profiling 
flavour that fits the target — **trace** sampling, **async-profiler** for a JVM 
service, **pprof** for a Go [...]
+- **It can propose profiling — the right kind for the target — and only you 
start it.** When metrics and traces can't localise a cause, the assistant 
presents a **decision card** explaining what it found, why profiling would 
help, and what it expects to reveal; nothing runs until you **approve it in the 
popout**, and only if you hold the profiling permission. It picks the profiling 
flavour that fits the target — **trace** sampling, **async-profiler** for a JVM 
service, **pprof** for a Go [...]
 - **Guided root-cause analysis.** Ask "what's the root cause?" and the 
assistant follows built-in investigation playbooks — a master method (locate 
the root service → calling chain → error stack; walk the dependency topology 
upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes 
only its client-side edge metric) plus latency, error-rate/SLA, saturation, 
middleware, **Kubernetes-workload**, and service-mesh specializations. It can 
also follow the **cross-layer hie [...]
 - **Bring your own LLM — vendor-neutral, and off by default.** Enable it with 
the new `ai:` config block (`HORIZON_AI_*`). The default transport is 
**OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local 
model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is 
also supported (`provider: bedrock`). The API key is a secret, env-only, 
redacted from logs and excluded from the audit trail. The launcher shows for 
every signed-in user so the AI-powered [...]
 - **Both prompts are yours.** The assistant's system prompt 
(`ai.systemPrompt`) and the starter example chips shown in an empty chat 
(`ai.starters`) ship with sensible defaults and can be replaced entirely in 
`horizon.yaml`.
diff --git a/apps/bff/src/ai/skill/triggers/tools.ts 
b/apps/bff/src/ai/skill/triggers/tools.ts
index 4732a33..7cc3244 100644
--- a/apps/bff/src/ai/skill/triggers/tools.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.ts
@@ -195,6 +195,7 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
         tip: a.tip,
         logs: a.logs,
         summary: a.summary,
+        ...(a.traceContext ? { traceContext: a.traceContext } : {}),
         reachable: a.reachable,
         error: a.error ?? null,
       });
diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts
index 037dabd..fc0a46c 100644
--- a/apps/bff/src/ai/types.ts
+++ b/apps/bff/src/ai/types.ts
@@ -38,7 +38,7 @@ import type {
   TraceListResponse,
   ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ProfilingLogLine, ProfilingSummary, ProfilingType } from 
'../logic/oap/profiling.js';
+import type { ProfilingLogLine, ProfilingSummary, ProfilingType, TraceContext 
} from '../logic/oap/profiling.js';
 
 export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid';
 
@@ -119,6 +119,9 @@ export interface ProfilingResultSpec {
   tip?: string | null;
   logs: ProfilingLogLine[];
   summary: ProfilingSummary;
+  /** trace only — the profiled segment's trace, rendered as a span waterfall
+   *  beside the flame (the trace+profiling combination). */
+  traceContext?: TraceContext;
   reachable: boolean;
   error?: string | null;
 }
diff --git a/apps/bff/src/logic/oap/profiling.ts 
b/apps/bff/src/logic/oap/profiling.ts
index cec3f7a..408ce59 100644
--- a/apps/bff/src/logic/oap/profiling.ts
+++ b/apps/bff/src/logic/oap/profiling.ts
@@ -31,6 +31,7 @@ import type {
   ProcessTopologyResponse,
   ProfileAnalyzationElement,
   ProfileAnalyzationTree,
+  ProfileSpan,
 } from '@skywalking-horizon-ui/api-client';
 import type { GraphqlOptions } from '../../client/graphql.js';
 import { graphqlPost } from '../../client/graphql.js';
@@ -56,6 +57,13 @@ export interface ProfilingSummary {
   frameCount: number;
 }
 
+/** trace only — the slowest profiled segment's trace, for the span waterfall
+ *  shown beside the flame (the trace+profiling combination). */
+export interface TraceContext {
+  traceId: string;
+  spans: ProfileSpan[];
+}
+
 export interface ProfilingAnalysis {
   profilingType: ProfilingType;
   taskId: string | null;
@@ -66,6 +74,7 @@ export interface ProfilingAnalysis {
   tip: string | null;
   logs: ProfilingLogLine[];
   summary: ProfilingSummary;
+  traceContext?: TraceContext;
   reachable: boolean;
   error?: string;
 }
@@ -105,7 +114,12 @@ const GET_PROFILE_TASK_SEGMENTS = /* GraphQL */ `
   query AiGetProfileTaskSegments($taskID: ID!) {
     segmentList: getProfileTaskSegments(taskID: $taskID) {
       traceId duration
-      spans { segmentId startTime endTime endpointName profiled }
+      spans {
+        spanId parentSpanId segmentId
+        refs { traceId parentSegmentId parentSpanId type }
+        serviceCode serviceInstanceName startTime endTime endpointName
+        type peer component isError layer profiled
+      }
     }
   }
 `;
@@ -408,14 +422,15 @@ async function analyzeTrace(
   };
 
   const segs = await graphqlPost<{
-    segmentList: Array<{ duration: number; spans: Array<{ segmentId: string; 
startTime: number; endTime: number; profiled: boolean }> }>;
+    segmentList: Array<{ traceId: string; duration: number; spans: 
ProfileSpan[] }>;
   }>(opts, GET_PROFILE_TASK_SEGMENTS, { taskID: task.id });
   const segments = segs.segmentList ?? [];
   base.summary.segmentCount = segments.length;
 
   // Slowest segments first, then one analyze query per profiled span, capped.
+  const bySlowest = [...segments].sort((a, b) => (b.duration ?? 0) - 
(a.duration ?? 0));
   const queries: Array<{ segmentId: string; timeRange: { start: number; end: 
number } }> = [];
-  for (const seg of [...segments].sort((a, b) => (b.duration ?? 0) - 
(a.duration ?? 0))) {
+  for (const seg of bySlowest) {
     for (const span of seg.spans ?? []) {
       if (!span.profiled) continue;
       queries.push({ segmentId: span.segmentId, timeRange: { start: 
span.startTime, end: span.endTime } });
@@ -424,6 +439,10 @@ async function analyzeTrace(
     if (queries.length >= MAX_TRACE_ANALYZE_QUERIES) break;
   }
 
+  // Carry the slowest profiled segment's trace for the waterfall beside the 
flame.
+  const waterfallSeg = bySlowest.find((s) => (s.spans ?? []).some((sp) => 
sp.profiled));
+  if (waterfallSeg) base.traceContext = { traceId: waterfallSeg.traceId, 
spans: waterfallSeg.spans };
+
   base.logs = await fetchTraceLogs(opts, task.id);
   if (!queries.length) return base; // task exists but nothing profiled yet
 
diff --git a/apps/ui/src/ai/ChatProfilingBlock.vue 
b/apps/ui/src/ai/ChatProfilingBlock.vue
index 8a0090c..1289e28 100644
--- a/apps/ui/src/ai/ChatProfilingBlock.vue
+++ b/apps/ui/src/ai/ChatProfilingBlock.vue
@@ -18,6 +18,7 @@
 import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import NativeTraceWaterfall from '@/layer/traces/NativeTraceWaterfall.vue';
 import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { ProfilingResultSpec } from './types';
 
@@ -25,6 +26,7 @@ const props = defineProps<{ n: number; spec: 
ProfilingResultSpec; capturedAt?: n
 const { t } = useI18n({ useScope: 'global' });
 
 const hasData = computed<boolean>(() => props.spec.reachable && 
props.spec.trees.some((tr) => tr.elements.length));
+const trace = computed(() => props.spec.traceContext ?? null);
 
 // Compact task facts — the flame carries no context of its own.
 const facts = computed<string[]>(() => {
@@ -53,9 +55,18 @@ const facts = computed<string[]>(() => {
       <span v-for="(f, i) in facts" :key="i" class="cpf__fact">{{ f }}</span>
     </div>
     <p v-if="spec.tip" class="cpf__tip">{{ spec.tip }}</p>
-    <div v-if="hasData" class="cpf__flame">
-      <ProfileFlameGraph :trees="spec.trees" :metric-key="spec.metricKey" />
-    </div>
+    <template v-if="hasData">
+      <template v-if="trace">
+        <div class="cpf__label">{{ t('Profiled trace') }} · <span 
class="cpf__tid">{{ trace.traceId }}</span></div>
+        <div class="cpf__wf">
+          <NativeTraceWaterfall :spans="trace.spans" :mark-profiled="true" />
+        </div>
+        <div class="cpf__label">{{ t('Flame graph') }}</div>
+      </template>
+      <div class="cpf__flame">
+        <ProfileFlameGraph :trees="spec.trees" :metric-key="spec.metricKey" />
+      </div>
+    </template>
     <div v-else class="cpf__empty">
       <template v-if="!spec.reachable">{{ t('Could not read the profile.') 
}}<span v-if="spec.error"> — {{ spec.error }}</span></template>
       <template v-else>{{ t('No profile data was collected in this task yet.') 
}}</template>
@@ -108,6 +119,26 @@ const facts = computed<string[]>(() => {
   font-size: 11px;
   color: var(--sw-warn, #d9a441);
 }
+.cpf__label {
+  padding: 8px 12px 4px;
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-3, #6b6f7a);
+}
+.cpf__tid {
+  font-family: var(--sw-mono, monospace);
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--sw-fg-2, #9aa0ac);
+}
+.cpf__wf {
+  height: 260px;
+  overflow: auto;
+  margin: 0 8px;
+  border: 1px solid var(--sw-line, #2a2d36);
+  border-radius: 6px;
+}
 .cpf__flame {
   height: 420px;
   margin-top: 8px;
diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts
index 332d4a4..ad0cdf6 100644
--- a/apps/ui/src/ai/types.ts
+++ b/apps/ui/src/ai/types.ts
@@ -31,6 +31,7 @@ import type {
   LogsResponse,
   BrowserErrorsResponse,
   ProcessTopologyResponse,
+  ProfileSpan,
 } from '@skywalking-horizon-ui/api-client';
 
 export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid';
@@ -100,6 +101,9 @@ export interface ProfilingResultSpec {
   tip?: string | null;
   logs: ProfilingLogLine[];
   summary: ProfilingSummary;
+  // trace only — the profiled segment's trace, rendered as a span waterfall
+  // beside the flame (the trace+profiling combination).
+  traceContext?: { traceId: string; spans: ProfileSpan[] };
   reachable: boolean;
   error?: string | null;
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 08cace9..1d26c08 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1647,6 +1647,8 @@
   "{n} frames": "{n} frames",
   "{n} processes": "{n} processes",
   "No process-conversation data was captured.": "No process-conversation data 
was captured.",
+  "Profiled trace": "Profiled trace",
+  "Flame graph": "Flame graph",
   "all endpoints": "all endpoints",
   "Could not read the profile.": "Could not read the profile.",
   "No profile data was collected in this task yet.": "No profile data was 
collected in this task yet.",

Reply via email to