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 0ac08373ef5d2c5f1f68c080a9db9b0b02985da9 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 17:38:44 2026 +0800 feat(ai): multi-type profiling proposals (trace/async/pprof/eBPF/network) propose_profiling now proposes the profiling flavor that fits the target instead of always trace. The agent reads which types the layer declares (kb_layer_capabilities.components) and the service's runtime language (kb_resolve_scope_drill now surfaces instance `language`) to choose; the tool re-validates the type against the layer, rejects a clear language mismatch (async=JVM, pprof=Go; OAP's UNKNOWN ⇒ trust the agent), and resolves the target instances server-side. On approve the decision card fires the right create call per type — trace / async / pprof / eBPF / network — with each call's unit + event/target params (async duration clamped to the 10-min server cap so the card stays honest; pprof duration/dumpPeriod gated per event). No per-layer facts are hardcoded in prompts: which types a layer supports and a service's language are runtime reads; only the universal "async=JVM, pprof=Go" tech fact is stated. Validated live on the demo: the agent proposed async for the Java service (instances resolved), self-corrected a pprof-on-Java request to async, and clamped a 15-min async request to 10. Review fixes folded in: new UI strings added to en.json (a missing templated key renders its placeholder literally); network-aware card copy with no bogus duration line; merged imports; CHANGELOG updated for the profiling feature. --- CHANGELOG.md | 2 +- apps/bff/src/ai/resources/prompts/skills.md | 1 + apps/bff/src/ai/resources/tools/triggers.yaml | 14 +++- apps/bff/src/ai/skill/metric-catalog/tools.ts | 6 +- apps/bff/src/ai/skill/triggers/tools.test.ts | 96 ++++++++++++++++++++---- apps/bff/src/ai/skill/triggers/tools.ts | 98 +++++++++++++++++++++++-- apps/bff/src/ai/types.ts | 22 +++++- apps/bff/src/logic/oap/profiling.ts | 27 +++++++ apps/ui/src/ai/ChatProposalBlock.vue | 101 +++++++++++++++++++++----- apps/ui/src/ai/types.ts | 13 +++- apps/ui/src/i18n/locales/en.json | 8 +- 11 files changed, 331 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1d5e2..b66da5e 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 — 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. On approve it starts the trace-profiling task; you then ask it to analyze the results once they've collected. It never triggers anything on its own. +- **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/resources/prompts/skills.md b/apps/bff/src/ai/resources/prompts/skills.md index 5ec74b9..4c7debb 100644 --- a/apps/bff/src/ai/resources/prompts/skills.md +++ b/apps/bff/src/ai/resources/prompts/skills.md @@ -71,4 +71,5 @@ SUB-PAGE TOOLS — mount an interactive feature view as a card (opens the full p TRIGGERS skill — gated profiling (propose_profiling) + analysis (analyze_profiling) - You are READ-ONLY except for propose_profiling. When metrics + pod logs cannot localise a cause and a PROFILE would confirm a specific hypothesis, call propose_profiling — it presents a decision card (your analysed cause, why profiling, what you expect); the USER approves it in a popout; it runs only after approval; you analyse the result in a LATER turn. Never assume it ran, and never fabricate its output. +- Pick the profilingType by READING, not assuming: kb_layer_capabilities(layer).components says which of trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only and pprof is Go-only (read the instance language via kb_resolve_scope_drill to choose), trace + eBPF are language-agnostic, network profiling yields a process graph. The tool re-validates the type against the layer and resolves target instances. - analyze_profiling(layer, service, profilingType) is READ-only — call it in that later turn (or whenever the user asks to analyse an existing profile) to render the flame graph of the most recent completed task. The reply lists the hottest self-time frames; use them to name the cause. If it says no data was collected yet, tell the user to let the task finish — do NOT retry immediately. diff --git a/apps/bff/src/ai/resources/tools/triggers.yaml b/apps/bff/src/ai/resources/tools/triggers.yaml index 915ac00..c219b81 100644 --- a/apps/bff/src/ai/resources/tools/triggers.yaml +++ b/apps/bff/src/ai/resources/tools/triggers.yaml @@ -22,7 +22,7 @@ propose_profiling: description: >- - PROPOSE a trace-profiling task (does NOT start it — the user approves a decision card first). Use only when metrics + traces cannot localise the cause and profiling would confirm a specific hypothesis (a hot method, lock contention, a slow call). You MUST supply cause (what you found), rationale (why profiling), and expectation (what it should reveal) — the user reads these before approving. Provide the service and, when possible, the specific endpoint to profile. + PROPOSE a profiling task (does NOT start it — the user approves a decision card first). Use only when metrics + traces cannot localise the cause and a profile would confirm a specific hypothesis (a hot method, lock contention, a slow call, a saturated process). FIRST pick the profilingType by READING, not assuming: kb_layer_capabilities(layer).components lists which profiling this layer supports (traceProfiling / asyncProfiling / pprofProfiling / ebpfProfiling / networkProfiling) — p [...] params: layer: >- OAP layer key, e.g. GENERAL @@ -30,10 +30,16 @@ propose_profiling: OAP service id (from list_services) service: >- service NAME - endpoint: >- - endpoint (API) name to profile, when known + profilingType: >- + the profiling flavor to propose — trace (in-process sampling), async (JVM async-profiler), pprof (Go), ebpf (kernel on/off-CPU), or network (process conversation graph). MUST be one the layer supports (kb_layer_capabilities.components); async needs a JVM service, pprof needs a Go service (check instance language). durationMinutes: >- - collection window in minutes (e.g. 5) + collection window in minutes, 1–15 (converted to each type's unit on start) + endpoint: >- + trace only — endpoint (API) name to profile, when known + event: >- + async/pprof only — the event to profile (default CPU; async also ALLOC/LOCK/WALL, pprof also HEAP/BLOCK/MUTEX) + targetType: >- + ebpf only — ON_CPU (default) or OFF_CPU cause: >- the analyzed cause SO FAR — what the investigation found rationale: >- diff --git a/apps/bff/src/ai/skill/metric-catalog/tools.ts b/apps/bff/src/ai/skill/metric-catalog/tools.ts index c0401d6..6b2645f 100644 --- a/apps/bff/src/ai/skill/metric-catalog/tools.ts +++ b/apps/bff/src/ai/skill/metric-catalog/tools.ts @@ -40,7 +40,7 @@ const SCOPES = ['service', 'instance', 'endpoint'] as const; const LIST_INSTANCES = /* GraphQL */ ` query DrillInstances($serviceId: ID!, $duration: Duration!) { - instances: listInstances(serviceId: $serviceId, duration: $duration) { id name } + instances: listInstances(serviceId: $serviceId, duration: $duration) { id name language } } `; const FIND_ENDPOINTS = /* GraphQL */ ` @@ -110,14 +110,14 @@ export function metricCatalogTools(ctx: AiRequestContext): StructuredToolInterfa if (!ctx.hasVerb('metrics:read')) return denied(); try { if (toScope === 'instance') { - const data = await graphqlPost<{ instances: Array<{ id: string; name: string }> }>( + const data = await graphqlPost<{ instances: Array<{ id: string; name: string; language?: string | null }> }>( ctx.opts, LIST_INSTANCES, { serviceId, duration: duration() }, ); return JSON.stringify({ toScope, - note: 'Re-query the SAME metric family at instance scope for each — OAP does not roll up between scopes.', + note: 'Re-query the SAME metric family at instance scope for each — OAP does not roll up between scopes. Each instance carries its runtime language (use it to pick a language-specific profiler: async→JVM, pprof→Go).', children: data.instances ?? [], }); } diff --git a/apps/bff/src/ai/skill/triggers/tools.test.ts b/apps/bff/src/ai/skill/triggers/tools.test.ts index 75ea2fc..b8b91b8 100644 --- a/apps/bff/src/ai/skill/triggers/tools.test.ts +++ b/apps/bff/src/ai/skill/triggers/tools.test.ts @@ -15,21 +15,43 @@ * limitations under the License. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +vi.mock('../../../logic/layers/capabilities.js', () => ({ + layerCapabilities: vi.fn(async () => ({ + components: ['traceProfiling', 'asyncProfiling', 'pprofProfiling', 'ebpfProfiling'], + })), +})); +vi.mock('../../../logic/oap/profiling.js', () => ({ + listServiceInstances: vi.fn(async () => [ + { id: 'i-1', name: 'inst-1', language: 'java' }, + { id: 'i-2', name: 'inst-2', language: 'java' }, + ]), + analyzeProfiling: vi.fn(), +})); + import { triggerTools } from './tools.js'; +import { layerCapabilities } from '../../../logic/layers/capabilities.js'; +import { listServiceInstances } from '../../../logic/oap/profiling.js'; import type { AiRequestContext } from '../../context.js'; function mockCtx(hasVerb: boolean) { const emitProposal = vi.fn(); - const ctx = { hasVerb: () => hasVerb, emitProposal } as unknown as AiRequestContext; + const ctx = { + hasVerb: () => hasVerb, + emitProposal, + uiTemplateClient: {}, + opts: {}, + window: { start: 's', end: 'e', step: 'MINUTE' }, + } as unknown as AiRequestContext; return { ctx, emitProposal }; } -const input = { +const base = { layer: 'general', serviceId: 'svc-1', service: 'agent::frontend', - endpoint: '/api/x', durationMinutes: 5, cause: 'p99 spike localised to one instance, but metrics do not name the hot path', rationale: 'metrics + traces cannot pinpoint the slow method', @@ -37,30 +59,78 @@ const input = { }; describe('propose_profiling', () => { - it('emits a decision card (with the reasoning) when the caller has profile:enable', async () => { + beforeEach(() => { + (layerCapabilities as unknown as Mock).mockResolvedValue({ + components: ['traceProfiling', 'asyncProfiling', 'pprofProfiling', 'ebpfProfiling'], + }); + }); + + it('emits a trace decision card (no instance resolution) with profile:enable', async () => { const { ctx, emitProposal } = mockCtx(true); const [propose] = triggerTools(ctx); - const out = await propose.invoke(input); + const out = await propose.invoke({ ...base, profilingType: 'trace', endpoint: '/api/x' }); expect(emitProposal).toHaveBeenCalledTimes(1); expect(emitProposal.mock.calls[0][0]).toMatchObject({ kind: 'profiling', profilingType: 'trace', layer: 'GENERAL', - service: 'agent::frontend', endpoint: '/api/x', - durationMinutes: 5, - cause: input.cause, - rationale: input.rationale, - expectation: input.expectation, }); - // The tool must NOT claim it started — it stops for user approval. + expect(emitProposal.mock.calls[0][0].instanceIds).toBeUndefined(); expect(String(out)).toMatch(/NOT running|approve/i); }); + it('resolves target instances + default event for async', async () => { + const { ctx, emitProposal } = mockCtx(true); + const [propose] = triggerTools(ctx); + await propose.invoke({ ...base, profilingType: 'async' }); + expect(emitProposal.mock.calls[0][0]).toMatchObject({ + profilingType: 'async', + instanceIds: ['i-1', 'i-2'], + instanceLabel: '2 instances', + events: ['CPU'], + }); + }); + + it('still proposes async when instances report UNKNOWN language (trusts the agent)', async () => { + (listServiceInstances as unknown as Mock).mockResolvedValueOnce([ + { id: 'i-1', name: 'inst-1', language: 'UNKNOWN' }, + ]); + const { ctx, emitProposal } = mockCtx(true); + const [propose] = triggerTools(ctx); + await propose.invoke({ ...base, profilingType: 'async' }); + expect(emitProposal).toHaveBeenCalledTimes(1); + expect(emitProposal.mock.calls[0][0]).toMatchObject({ profilingType: 'async' }); + }); + + it('clamps async duration to the 10-minute (600s) server cap', async () => { + const { ctx, emitProposal } = mockCtx(true); + const [propose] = triggerTools(ctx); + await propose.invoke({ ...base, profilingType: 'async', durationMinutes: 15 }); + expect(emitProposal.mock.calls[0][0].durationMinutes).toBe(10); + }); + + it('refuses pprof (Go) when the instances report a JVM language', async () => { + const { ctx, emitProposal } = mockCtx(true); + const [propose] = triggerTools(ctx); + const out = await propose.invoke({ ...base, profilingType: 'pprof' }); + expect(emitProposal).not.toHaveBeenCalled(); + expect(String(out)).toMatch(/Go-only|match the profiler/i); + }); + + it('refuses a type the layer does not declare', async () => { + (layerCapabilities as unknown as Mock).mockResolvedValueOnce({ components: ['traceProfiling'] }); + const { ctx, emitProposal } = mockCtx(true); + const [propose] = triggerTools(ctx); + const out = await propose.invoke({ ...base, profilingType: 'ebpf' }); + expect(emitProposal).not.toHaveBeenCalled(); + expect(String(out)).toMatch(/does not support/i); + }); + it('does NOT propose (or emit) without profile:enable', async () => { const { ctx, emitProposal } = mockCtx(false); const [propose] = triggerTools(ctx); - const out = await propose.invoke(input); + const out = await propose.invoke({ ...base, profilingType: 'trace' }); expect(emitProposal).not.toHaveBeenCalled(); expect(String(out)).toMatch(/permission|profile:enable/i); }); diff --git a/apps/bff/src/ai/skill/triggers/tools.ts b/apps/bff/src/ai/skill/triggers/tools.ts index e816d69..fde48ad 100644 --- a/apps/bff/src/ai/skill/triggers/tools.ts +++ b/apps/bff/src/ai/skill/triggers/tools.ts @@ -28,9 +28,33 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import type { StructuredToolInterface } from '@langchain/core/tools'; import type { AiRequestContext } from '../../context.js'; +import type { ProfilingProposalType } from '../../types.js'; import { toolPrompt } from '../../resources/loader.js'; -import { analyzeProfiling } from '../../../logic/oap/profiling.js'; +import { analyzeProfiling, listServiceInstances } from '../../../logic/oap/profiling.js'; import type { ProfilingAnalysis } from '../../../logic/oap/profiling.js'; +import { layerCapabilities } from '../../../logic/layers/capabilities.js'; + +// Which template `components` flag gates each proposable profiling type — the +// layer must declare it for the type to be offer-able. Read at runtime; never +// hardcode which layers support what. +const PROFILING_COMPONENT: Record<ProfilingProposalType, string> = { + trace: 'traceProfiling', + async: 'asyncProfiling', + pprof: 'pprofProfiling', + ebpf: 'ebpfProfiling', + network: 'networkProfiling', +}; +function supportedProfilingTypes(components: string[]): ProfilingProposalType[] { + return (Object.keys(PROFILING_COMPONENT) as ProfilingProposalType[]).filter((ty) => + components.includes(PROFILING_COMPONENT[ty]), + ); +} + +const ASYNC_EVENTS = ['CPU', 'ALLOC', 'LOCK', 'WALL', 'CTIMER', 'ITIMER']; +const PPROF_EVENTS = ['CPU', 'HEAP', 'BLOCK', 'MUTEX', 'GOROUTINE', 'ALLOCS', 'THREADCREATE']; +// async caps at 600s server-side; keep the proposed minutes honest so the card +// and the fired task agree (trace/pprof are minutes; eBPF's 30-min cap is looser). +const MAX_ASYNC_MINUTES = 10; // Top self-heavy frames as text so the agent can reason about the hot path // (the flame itself is rendered for the user, not readable by the model). @@ -49,23 +73,78 @@ function summarizeProfile(a: ProfilingAnalysis): string { export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] { const t = toolPrompt('triggers', 'propose_profiling'); const propose = tool( - async ({ layer, serviceId, service, endpoint, durationMinutes, cause, rationale, expectation }): Promise<string> => { + async ({ layer, serviceId, service, profilingType, durationMinutes, endpoint, event, targetType, cause, rationale, expectation }): Promise<string> => { if (!ctx.hasVerb('profile:enable')) { return 'You lack permission to start profiling (profile:enable). Do not propose it; explain what a profiling task would reveal instead.'; } + const layerKey = layer.toUpperCase(); + const cap = await layerCapabilities(ctx.uiTemplateClient, layerKey); + if (cap) { + const supported = supportedProfilingTypes(cap.components); + if (!supported.includes(profilingType)) { + return `The ${layerKey} layer does not support ${profilingType} profiling (it supports: ${supported.join(', ') || 'none'}). Read kb_layer_capabilities and propose a supported type, or tell the user profiling is unavailable here.`; + } + } + // async / pprof / network target instances — resolve them server-side so + // the card can fire without the agent enumerating ids. + let instanceIds: string[] | undefined; + let instanceLabel: string | undefined; + if (profilingType === 'async' || profilingType === 'pprof' || profilingType === 'network') { + const insts = await listServiceInstances(ctx.opts, serviceId, ctx.window); + if (!insts.length) { + return `No instances found for ${service} in the current window — ${profilingType} profiling targets instances. Widen the time range, or use trace/eBPF profiling instead.`; + } + // async-profiler is JVM-only, pprof is Go-only — reject a clear language + // mismatch. OAP reports "UNKNOWN" (never null) when it can't tell, so drop + // that too and trust the agent. Uses the runtime language, not a per-layer + // assumption. + if (profilingType === 'async' || profilingType === 'pprof') { + const langs = [ + ...new Set(insts.map((i) => (i.language ?? '').toLowerCase()).filter((l) => l && l !== 'unknown')), + ]; + const wantGo = profilingType === 'pprof'; + const matches = langs.some((l) => (wantGo ? l.includes('go') : l.includes('java'))); + if (langs.length && !matches) { + return `${service}'s instances report ${langs.join('/')}, but ${profilingType} profiling is ${wantGo ? 'Go' : 'JVM'}-only. Propose ${wantGo ? 'async (JVM) or trace' : 'pprof (Go) or trace'} instead — match the profiler to the runtime language.`; + } + } + if (profilingType === 'network') { + instanceIds = [insts[0].id]; + instanceLabel = insts[0].name; + } else { + instanceIds = insts.map((i) => i.id); + instanceLabel = insts.length === 1 ? insts[0].name : `${insts.length} instances`; + } + } + // Normalise the event to one this profiler knows (default CPU) so the card + // never fires a garbage event the BFF would silently drop. + let events: string[] | undefined; + if (profilingType === 'async' || profilingType === 'pprof') { + const known = profilingType === 'async' ? ASYNC_EVENTS : PPROF_EVENTS; + const ev = (event ?? 'CPU').toUpperCase(); + events = [known.includes(ev) ? ev : 'CPU']; + } + const effMinutes = profilingType === 'async' ? Math.min(durationMinutes, MAX_ASYNC_MINUTES) : durationMinutes; ctx.emitProposal({ kind: 'profiling', - profilingType: 'trace', - layer: layer.toUpperCase(), + profilingType, + layer: layerKey, serviceId, service, - endpoint, - durationMinutes, + durationMinutes: effMinutes, + ...(endpoint ? { endpoint } : {}), + ...(instanceIds ? { instanceIds, instanceLabel } : {}), + ...(events ? { events } : {}), + ...(profilingType === 'ebpf' ? { targetType: targetType ?? 'ON_CPU', processLabels: [] } : {}), cause, rationale, expectation, }); - return 'Proposed a trace-profiling task to the user as a decision card. It is NOT running — the user must approve it. Do not analyze results now; stop here and tell the user to approve it, and that you will analyze the profile once it has collected data (ask you to analyze when ready).'; + const analyseNote = + profilingType === 'network' + ? 'once it has collected data, its result appears as a process-conversation topology' + : 'once it has collected data, call analyze_profiling to render the flame'; + return `Proposed a ${profilingType}-profiling task to the user as a decision card. It is NOT running — the user must approve it. Do not analyze now; stop here, tell the user to approve it, and that ${analyseNote}.`; }, { name: 'propose_profiling', @@ -74,8 +153,11 @@ export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] { layer: z.string().describe(t.p('layer')), serviceId: z.string().describe(t.p('serviceId')), service: z.string().describe(t.p('service')), - endpoint: z.string().optional().describe(t.p('endpoint')), + profilingType: z.enum(['trace', 'async', 'pprof', 'ebpf', 'network']).describe(t.p('profilingType')), durationMinutes: z.number().int().min(1).max(15).describe(t.p('durationMinutes')), + endpoint: z.string().optional().describe(t.p('endpoint')), + event: z.string().optional().describe(t.p('event')), + targetType: z.enum(['ON_CPU', 'OFF_CPU']).optional().describe(t.p('targetType')), cause: z.string().describe(t.p('cause')), rationale: z.string().describe(t.p('rationale')), expectation: z.string().describe(t.p('expectation')), diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts index 7cf16f4..ff36791 100644 --- a/apps/bff/src/ai/types.ts +++ b/apps/bff/src/ai/types.ts @@ -68,16 +68,30 @@ export interface SubPageSpec { * it — it presents a decision card (what it found, why this action, what it * expects) and the user approves or dismisses in a popout. On approve the UI * calls the existing verb-gated create route; on dismiss nothing happens. */ +/** The five profiling flavors an agent can propose (network adds to the four + * flame-analysable ones — its result is a process topology, not a flame). */ +export type ProfilingProposalType = 'trace' | 'async' | 'pprof' | 'ebpf' | 'network'; + export interface ProposalSpec { - /** Extensible; profiling first. */ kind: 'profiling'; - /** trace-profiling for now. */ - profilingType: 'trace'; + profilingType: ProfilingProposalType; layer: string; serviceId: string; service: string; - endpoint?: string; + /** Agent-facing collection window; the card converts it to each type's unit + * (trace/pprof minutes, async/eBPF seconds, network none). */ durationMinutes: number; + /** trace — the endpoint to sample. */ + endpoint?: string; + /** async / pprof / network — target instance ids the tool resolved server-side. */ + instanceIds?: string[]; + /** Display label for the resolved instances ("3 instances" / a single name). */ + instanceLabel?: string; + /** async (multi) / pprof (single) — profiling events, e.g. ['CPU']. */ + events?: string[]; + /** eBPF — ON_CPU / OFF_CPU + optional process-label filter (empty ⇒ all). */ + targetType?: 'ON_CPU' | 'OFF_CPU'; + processLabels?: string[]; /** The analyzed cause so far — what the investigation found. */ cause: string; /** Why this action is the right next step. */ diff --git a/apps/bff/src/logic/oap/profiling.ts b/apps/bff/src/logic/oap/profiling.ts index 69d6b6c..8a0d117 100644 --- a/apps/bff/src/logic/oap/profiling.ts +++ b/apps/bff/src/logic/oap/profiling.ts @@ -194,6 +194,33 @@ const ANALYSIS_EBPF_RESULT = /* GraphQL */ ` } `; +const LIST_INSTANCES = /* GraphQL */ ` + query AiListServiceInstances($serviceId: ID!, $duration: Duration!) { + instances: listInstances(serviceId: $serviceId, duration: $duration) { id name language } + } +`; + +export interface ServiceInstanceInfo { + id: string; + name: string; + language: string | null; +} + +/** Resolve a service's instances (with runtime language) — the propose tool fills + * async/pprof/network target ids from this and reads language to match the profiler. */ +export async function listServiceInstances( + opts: GraphqlOptions, + serviceId: string, + window: { start: string; end: string; step: string }, +): Promise<ServiceInstanceInfo[]> { + const data = await graphqlPost<{ instances: Array<{ id: string; name: string; language?: string | null }> }>( + opts, + LIST_INSTANCES, + { serviceId, duration: { start: window.start, end: window.end, step: window.step } }, + ); + return (data.instances ?? []).map((i) => ({ id: i.id, name: i.name, language: i.language ?? null })); +} + const ENCODED_ID = /^[A-Za-z0-9+/=]+\.\d+$/; async function resolveServiceId(opts: GraphqlOptions, layerKey: string, serviceArg: string): Promise<string | null> { diff --git a/apps/ui/src/ai/ChatProposalBlock.vue b/apps/ui/src/ai/ChatProposalBlock.vue index c9f154e..4e2833e 100644 --- a/apps/ui/src/ai/ChatProposalBlock.vue +++ b/apps/ui/src/ai/ChatProposalBlock.vue @@ -18,36 +18,80 @@ reasoning — the analysed cause, why profiling, what it expects — and only on Approve does it call the existing verb-gated profile-create route. --> <script setup lang="ts"> -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { bff } from '@/api/client'; +import type { AsyncProfilingEvent, PprofTaskCreationRequest } from '@skywalking-horizon-ui/api-client'; import Icon from '@/components/icons/Icon.vue'; import { useAiConversations } from './useAiConversations'; -import type { ProposalBlock } from './types'; +import type { ProposalBlock, ProposalSpec } from './types'; + +// pprof: only CPU/BLOCK/MUTEX carry a duration; only BLOCK/MUTEX carry a dump +// period. Sending them for point-in-time events (HEAP/GOROUTINE/…) is invalid. +const PPROF_DURATION_EVENTS = ['CPU', 'BLOCK', 'MUTEX']; +const PPROF_DUMP_PERIOD_EVENTS = ['BLOCK', 'MUTEX']; const props = defineProps<{ block: ProposalBlock }>(); const { t } = useI18n({ useScope: 'global' }); const conv = useAiConversations(); const busy = ref(false); +const TYPE_LABEL: Record<ProposalSpec['profilingType'], string> = { + trace: 'trace profiling', + async: 'JVM async-profiler', + pprof: 'Go pprof profiling', + ebpf: 'eBPF profiling', + network: 'network profiling', +}; + +// The five create calls share nothing but serviceId — fire the right one per +// type, converting the agent-facing minutes to each call's unit. Returns the +// task id + a normalised ok/error (eBPF/network signal success via `status`). +async function fireTask(s: ProposalSpec): Promise<{ ok: boolean; taskId?: string; error?: string }> { + const layer = s.layer.toLowerCase(); + const mins = s.durationMinutes; + if (s.profilingType === 'trace') { + const r = await bff.profile.create(layer, { + serviceId: s.serviceId, endpointName: s.endpoint ?? '', startTime: Date.now(), + duration: mins, minDurationThreshold: 0, dumpPeriod: 10, maxSamplingCount: 5, + }); + return { ok: r.reachable && !r.errorReason, taskId: r.id, error: r.errorReason ?? r.error }; + } + if (s.profilingType === 'async') { + const r = await bff.asyncProfile.create(layer, { + serviceId: s.serviceId, serviceInstanceIds: s.instanceIds ?? [], + duration: mins * 60, events: (s.events ?? ['CPU']) as AsyncProfilingEvent[], execArgs: '', + }); + return { ok: r.reachable && !r.errorReason && r.code !== 'ARGUMENT_ERROR', taskId: r.id, error: r.errorReason ?? r.error }; + } + if (s.profilingType === 'pprof') { + const ev = s.events?.[0] ?? 'CPU'; + const body: PprofTaskCreationRequest = { serviceId: s.serviceId, serviceInstanceIds: s.instanceIds ?? [], events: ev }; + if (PPROF_DURATION_EVENTS.includes(ev)) body.duration = mins; + if (PPROF_DUMP_PERIOD_EVENTS.includes(ev)) body.dumpPeriod = 1; + const r = await bff.pprof.create(layer, body); + return { ok: r.reachable && !r.errorReason, taskId: r.id, error: r.errorReason ?? r.error }; + } + if (s.profilingType === 'ebpf') { + const r = await bff.ebpf.create(layer, { + serviceId: s.serviceId, processLabels: s.processLabels ?? [], startTime: Date.now(), + duration: mins * 60, targetType: s.targetType ?? 'ON_CPU', + }); + return { ok: r.reachable && r.status && !r.errorReason, taskId: r.id, error: r.errorReason ?? r.error }; + } + const r = await bff.networkProfile.create({ + instanceId: s.instanceIds?.[0] ?? '', + samplings: [{ when4xx: true, when5xx: true, settings: { requireCompleteRequest: true, requireCompleteResponse: true } }], + }); + return { ok: r.reachable && r.status && !r.errorReason, taskId: r.id, error: r.errorReason ?? r.error }; +} + async function approve(): Promise<void> { - const s = props.block.spec; busy.value = true; try { - const res = await bff.profile.create(s.layer.toLowerCase(), { - serviceId: s.serviceId, - endpointName: s.endpoint ?? '', - startTime: Date.now(), - duration: s.durationMinutes, - minDurationThreshold: 0, - dumpPeriod: 10, - maxSamplingCount: 5, - }); - if (!res.reachable || res.errorReason) { - conv.resolveProposal(props.block, 'failed', { error: res.errorReason ?? res.error ?? '' }); - } else { - conv.resolveProposal(props.block, 'approved', { taskId: res.id }); - } + const res = await fireTask(props.block.spec); + if (res.ok) conv.resolveProposal(props.block, 'approved', { taskId: res.taskId }); + else conv.resolveProposal(props.block, 'failed', { error: res.error ?? '' }); } catch (e) { conv.resolveProposal(props.block, 'failed', { error: e instanceof Error ? e.message : String(e) }); } finally { @@ -57,13 +101,30 @@ async function approve(): Promise<void> { function dismiss(): void { conv.resolveProposal(props.block, 'dismissed'); } + +const typeLabel = computed<string>(() => TYPE_LABEL[props.block.spec.profilingType]); +// network profiling has no flame analysis — its result is a process graph. +const approvedMessage = computed<string>(() => + props.block.spec.profilingType === 'network' + ? t('Network profiling started — the process-conversation graph appears in the Network Profiling view.') + : t('Profiling started — ask me to analyze the results once it has collected data.'), +); +// One target line adapted to the type: endpoint for trace, resolved instances +// for async/pprof/network, the CPU target for eBPF. +const targetDetail = computed<string>(() => { + const s = props.block.spec; + if (s.profilingType === 'trace' && s.endpoint) return s.endpoint; + if (s.profilingType === 'ebpf') return s.targetType ?? 'ON_CPU'; + if (s.instanceLabel) return s.instanceLabel; + return ''; +}); </script> <template> <div class="prop" :class="`is-${block.status}`"> <div class="prop__head"> <Icon name="ai" :size="15" /> - <span>{{ t('Suggested action: start trace profiling') }}</span> + <span>{{ t('Suggested action: start {type}', { type: typeLabel }) }}</span> </div> <dl class="prop__facts"> <div><dt>{{ t('Cause') }}</dt><dd>{{ block.spec.cause }}</dd></div> @@ -71,7 +132,7 @@ function dismiss(): void { <div><dt>{{ t('Expected') }}</dt><dd>{{ block.spec.expectation }}</dd></div> </dl> <div class="prop__target"> - {{ block.spec.service }}<template v-if="block.spec.endpoint"> · {{ block.spec.endpoint }}</template> · {{ block.spec.durationMinutes }}m + {{ block.spec.service }}<template v-if="targetDetail"> · {{ targetDetail }}</template><template v-if="block.spec.profilingType !== 'network'"> · {{ block.spec.durationMinutes }}m</template> </div> <div v-if="block.status === 'pending'" class="prop__actions"> <button type="button" class="prop__btn" :disabled="busy" @click="dismiss">{{ t('Dismiss') }}</button> @@ -80,7 +141,7 @@ function dismiss(): void { </button> </div> <p v-else-if="block.status === 'approved'" class="prop__out"> - {{ t('Profiling started — ask me to analyze the results once it has collected data.') }} + {{ approvedMessage }} </p> <p v-else-if="block.status === 'dismissed'" class="prop__out">{{ t('Dismissed.') }}</p> <p v-else class="prop__out prop__out--err">{{ t('Could not start profiling.') }} {{ block.error }}</p> diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts index 672073e..8f29a6d 100644 --- a/apps/ui/src/ai/types.ts +++ b/apps/ui/src/ai/types.ts @@ -58,15 +58,22 @@ export interface SubPageSpec { } // A proposed mutating action (profiling) — a decision card the user approves in -// a popout; the agent never fires it. Carries the reasoning it must justify. +// a popout; the agent never fires it. Carries the reasoning it must justify plus +// the type-specific params the approve handler fires with. Mirror of BFF ProposalSpec. +export type ProfilingProposalType = 'trace' | 'async' | 'pprof' | 'ebpf' | 'network'; export interface ProposalSpec { kind: 'profiling'; - profilingType: 'trace'; + profilingType: ProfilingProposalType; layer: string; serviceId: string; service: string; - endpoint?: string; durationMinutes: number; + endpoint?: string; + instanceIds?: string[]; + instanceLabel?: string; + events?: string[]; + targetType?: 'ON_CPU' | 'OFF_CPU'; + processLabels?: string[]; cause: string; rationale: string; expectation: string; diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 1b9d2ee..dfbefef 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1639,7 +1639,13 @@ "Cannot reach the server.": "Cannot reach the server.", "Stop": "Stop", "Model": "Model", - "Suggested action: start trace profiling": "Suggested action: start trace profiling", + "Suggested action: start {type}": "Suggested action: start {type}", + "{n} segments": "{n} segments", + "{n} frames": "{n} frames", + "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.", + "Network profiling started — the process-conversation graph appears in the Network Profiling view.": "Network profiling started — the process-conversation graph appears in the Network Profiling view.", "Cause": "Cause", "Why profiling": "Why profiling", "Expected": "Expected",
