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.",
