Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ summary:focus-visible {
.topbar__trace-meta {
display: flex;
align-items: center;
gap: 18px;
gap: 12px;
}

.topbar__trace-meta > span {
Expand All @@ -107,6 +107,10 @@ summary:focus-visible {
font: 650 0.78rem/1.3 var(--font-mono);
}

.topbar__trace-meta .status-badge {
min-height: 28px;
}

.workflow-tabs {
position: sticky;
top: var(--topbar-height);
Expand Down Expand Up @@ -1487,14 +1491,18 @@ summary:focus-visible {
justify-self: end;
}

.topbar__trace-meta {
.topbar__trace-meta {
grid-column: 1 / -1;
justify-content: space-between;
gap: 8px;
border-top: 1px solid var(--color-line);
padding-top: 8px;
}

.topbar__trace-meta > .status-badge:only-child {
margin-left: auto;
}

.workflow-tabs {
position: sticky;
top: 0;
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/features/workbench/StackFlowWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ export function StackFlowWorkbench() {
hasDetectedApis={shell.hasDetectedApis}
requestReady={shell.requestReady}
traceId={shell.traceId}
traceResultStatus={shell.traceResultStatus}
traceDisplayStatus={shell.traceDisplayStatus}
traceEventCount={shell.traceEventCount}
traceCollectionPresentation={shell.traceCollectionPresentation}
onViewChange={shell.setActiveView}
>
{shell.activeView === 'project' ? <ProjectView model={projectView} /> : null}
Expand Down
31 changes: 25 additions & 6 deletions frontend/src/features/workbench/components/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ describe('AppShell', () => {
hasDetectedApis
requestReady
traceId={null}
traceResultStatus="IDLE"
traceDisplayStatus="대기"
traceEventCount={0}
traceCollectionPresentation={{ label: 'Trace 대기', tone: 'neutral' }}
onViewChange={vi.fn()}
>
<section>작업 화면</section>
Expand All @@ -39,9 +37,7 @@ describe('AppShell', () => {
hasDetectedApis
requestReady={false}
traceId={null}
traceResultStatus="IDLE"
traceDisplayStatus="대기"
traceEventCount={0}
traceCollectionPresentation={{ label: 'Trace 대기', tone: 'neutral' }}
onViewChange={vi.fn()}
>
<section>API 화면</section>
Expand All @@ -51,4 +47,27 @@ describe('AppShell', () => {
expect(within(container).getAllByText('정적 분석만')).toHaveLength(2)
expect(within(container).getByRole('button', { name: /API 요청/ })).toHaveAttribute('aria-current', 'page')
})

it('shows only the trace identity and collection lifecycle in the runtime header', () => {
render(
<AppShell
activeView="runtime"
projectName="trace-lab"
projectStatus="SUCCESS"
analysisTarget="external"
hasDetectedApis
requestReady
traceId="1234567890abcdef"
traceCollectionPresentation={{ label: 'Span 수집 시간 초과', tone: 'warning' }}
onViewChange={vi.fn()}
>
<section>Trace 화면</section>
</AppShell>,
)

expect(screen.getByText('12345678')).toBeInTheDocument()
expect(screen.getByText('Span 수집 시간 초과')).toBeInTheDocument()
expect(screen.queryByText('결과')).not.toBeInTheDocument()
expect(screen.queryByText('이벤트')).not.toBeInTheDocument()
})
})
20 changes: 8 additions & 12 deletions frontend/src/features/workbench/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ReactNode } from 'react'
import { StatusBadge } from '../../../components/StatusBadge'
import type { StatusTone } from '../../../components/StatusBadge'
import { WorkflowTabs } from '../../../components/WorkflowTabs'
import { getWorkflowNavigationState } from '../../../components/workflowNavigation'
import { getResultStatusLabel } from '../../../ui/copy'
import type { EventStatus, ProjectAnalysisStatus } from '../../../types/trace'
import type { ProjectAnalysisStatus } from '../../../types/trace'
import type { AnalysisTarget } from '../types'
import type { ViewMode } from '../../../ui/copy'

Expand All @@ -15,9 +15,7 @@ type AppShellProps = {
hasDetectedApis: boolean
requestReady: boolean
traceId: string | null
traceResultStatus: EventStatus | 'IDLE'
traceDisplayStatus: string
traceEventCount: number
traceCollectionPresentation: { label: string; tone: StatusTone }
onViewChange: (view: ViewMode) => void
children: ReactNode
}
Expand All @@ -30,9 +28,7 @@ export function AppShell({
hasDetectedApis,
requestReady,
traceId,
traceResultStatus,
traceDisplayStatus,
traceEventCount,
traceCollectionPresentation,
onViewChange,
children,
}: AppShellProps) {
Expand All @@ -55,10 +51,10 @@ export function AppShell({
</div>
{activeView === 'runtime' ? (
<div className="topbar__trace-meta">
<span><small>Trace ID</small><strong>{traceId?.slice(0, 8) ?? '대기'}</strong></span>
<span><small>결과</small><strong>{getResultStatusLabel(traceResultStatus)}</strong></span>
<span><small>상태</small><strong>{traceDisplayStatus}</strong></span>
<span><small>이벤트</small><strong>{traceEventCount}</strong></span>
{traceId ? <span><small>Trace ID</small><strong title={traceId}>{traceId.slice(0, 8)}</strong></span> : null}
<StatusBadge tone={traceCollectionPresentation.tone}>
{traceCollectionPresentation.label}
</StatusBadge>
</div>
) : (
<StatusBadge tone={activeNavigationState.tone}>
Expand Down
42 changes: 11 additions & 31 deletions frontend/src/features/workbench/hooks/useWorkbenchController.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { buildGraph, getNodeDetail } from '../../../lib/graph'
import { buildWaterfall, getPrimaryFailureEvent } from '../../../lib/waterfall'
import type { EventStatus } from '../../../types/trace'
import { EVENT_STATUS_LABEL, STREAM_STATUS_LABEL, TRACE_COLLECTION_STATUS_LABEL } from '../../../ui/copy'
import type { StatusTone } from '../../../components/StatusBadge'
import type { ViewMode } from '../../../ui/copy'
import { EMPTY_API_DEFINITION, EMPTY_DOMAIN, FALLBACK_API_CATALOG, FALLBACK_PROJECT_STRUCTURE, PROJECT_STATUS_CONTENT } from '../fixtures'
import { createRequestEntry, filterApis, formatResponseBody } from '../requestModel'
import { buildFailurePropagationPath, filterTraceHistory, getDefaultInspectionEvent, getInspectorEvent, getTraceOutcome } from '../traceModel'
import { buildFailurePropagationPath, buildTraceOutcomePresentation, filterTraceHistory, getDefaultInspectionEvent, getInspectorEvent, getTraceCollectionPresentation } from '../traceModel'
import {
buildCommonProjectLayers,
buildDomainStructurePath,
Expand Down Expand Up @@ -90,8 +87,9 @@ export function useWorkbenchController() {
?? graph.states.find((state) => state.id === primaryFailureNodeId)?.label
?? primaryFailureEvent?.component
?? null
const traceOutcome = runtime.traceDetail ? getTraceOutcome(runtime.traceDetail, primaryFailureEvent) : null
const traceCollectionTimedOut = runtime.traceDetail?.traceCollectionStatus === 'TIMED_OUT'
const traceOutcome = runtime.traceDetail
? buildTraceOutcomePresentation(runtime.traceDetail, primaryFailureEvent)
: null
const failurePropagationPath = buildFailurePropagationPath(runtime.traceDetail?.events ?? [], primaryFailureEvent)
const filteredRecentTraces = filterTraceHistory(runtime.recentTraces, runtime.traceHistoryFilter)
const projectMetrics = buildProjectMetrics(project.projectStructure)
Expand Down Expand Up @@ -138,24 +136,11 @@ export function useWorkbenchController() {
? '요청 후 Trace 확인'
: externalTraceConfigured ? 'Trace 설정됨 · 확인 전' : '외부 API 요청'
: '정적 분석만 가능'
const traceDisplayStatus = runtime.streamStatus === 'connection_timeout'
? STREAM_STATUS_LABEL[runtime.streamStatus]
: traceCollectionTimedOut
? TRACE_COLLECTION_STATUS_LABEL.TIMED_OUT
: runtime.traceDetail?.source === 'OPENTELEMETRY' && runtime.streamStatus !== 'idle'
? TRACE_COLLECTION_STATUS_LABEL[runtime.traceCollectionStatus]
: runtime.traceDetail
? EVENT_STATUS_LABEL[runtime.traceDetail.resultStatus]
: STREAM_STATUS_LABEL[runtime.streamStatus]
const traceDisplayTone: StatusTone = traceCollectionTimedOut
? 'warning'
: runtime.streamStatus === 'idle' && runtime.traceDetail
? runtime.traceDetail.resultStatus === 'SUCCESS' ? 'success'
: runtime.traceDetail.resultStatus === 'WARNING' ? 'warning' : 'error'
: runtime.streamStatus === 'completed' ? 'success'
: runtime.streamStatus === 'error' ? 'error'
: runtime.streamStatus === 'connection_timeout' ? 'warning'
: runtime.streamStatus === 'idle' ? 'neutral' : 'info'
const traceCollectionPresentation = getTraceCollectionPresentation(
runtime.streamStatus,
runtime.traceDetail?.traceCollectionStatus ?? runtime.traceCollectionStatus,
Boolean(runtime.traceDetail),
)
const selectedApiMethodLabel = getApiMethodLabel(selectedApi)
const selectedApiMethodClassName = getApiMethodBadgeClassName(selectedApi)
const formattedResponseBody = useMemo(() => request.lastResponseBody
Expand All @@ -167,9 +152,6 @@ export function useWorkbenchController() {
const graphFitKey = `${runtime.traceDetail?.traceId ?? 'empty'}-${runtime.traceDetail?.events.length ?? 0}`
const selectedTraceId = runtime.traceDetail?.traceId
const setSelectedNodeId = runtime.setSelectedNodeId
const currentResultStatus: EventStatus | 'IDLE' = request.externalResponse?.resultStatus
?? runtime.traceDetail?.resultStatus
?? 'IDLE'
const effectiveProjectStatus = project.analysisState === 'error' || project.analysisResultState === 'none'
? 'FAILED'
: project.projectStructure.analysisStatus
Expand Down Expand Up @@ -210,9 +192,7 @@ export function useWorkbenchController() {
hasDetectedApis: hasUsableApis,
requestReady: hasUsableApis && hasConcreteMethod,
traceId: runtime.traceDetail?.traceId ?? null,
traceResultStatus: currentResultStatus,
traceDisplayStatus,
traceEventCount: runtime.traceDetail?.events.length ?? 0,
traceCollectionPresentation,
},
projectView: {
...project, ...projectActions,
Expand Down Expand Up @@ -243,7 +223,7 @@ export function useWorkbenchController() {
selectedNode, inspectorEvent, primaryFailureLabel, traceOutcome,
failurePropagationPath, filteredRecentTraces, activeRoute, traceComparison,
traceServiceGroups,
traceDisplayTone, traceDisplayStatus, selectedApi, selectedDomain,
selectedApi, selectedDomain,
analysisTarget: project.analysisTarget,
runtimeSupported, externalTraceConfigured, selectedApiMethodLabel,
},
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/features/workbench/traceModel.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ComponentType, EventStatus, GraphNodeState, TraceDetail, TraceEvent, TraceSummary } from '../../types/trace'
import { buildFailurePropagationPath, filterTraceHistory, getDefaultInspectionEvent, getExceptionLocation, getInspectorEvent, getKeyMetadata, getTraceOutcome, MAX_RECENT_TRACES, resolveCodeLocation, upsertRecentTrace } from './traceModel'
import { buildFailurePropagationPath, buildTraceOutcomePresentation, filterTraceHistory, getDefaultInspectionEvent, getExceptionLocation, getInspectorEvent, getKeyMetadata, getTraceCollectionPresentation, MAX_RECENT_TRACES, resolveCodeLocation, upsertRecentTrace } from './traceModel'

function event(
spanId: string,
Expand Down Expand Up @@ -43,17 +43,28 @@ function trace(resultStatus: EventStatus, events: TraceEvent[]): TraceDetail {
describe('trace inspection model', () => {
it('classifies an internal Redis error followed by success as recovered', () => {
const redis = event('redis', 'server', 'REDIS', 'ERROR')
expect(getTraceOutcome(trace('SUCCESS', [redis]), redis)).toBe('recovered')
expect(buildTraceOutcomePresentation(trace('SUCCESS', [redis]), redis).outcome).toBe('recovered')
})

it('classifies a terminal timeout as failure', () => {
const postgres = event('postgres', 'server', 'POSTGRESQL', 'TIMEOUT')
expect(getTraceOutcome(trace('TIMEOUT', [postgres]), postgres)).toBe('failure')
expect(buildTraceOutcomePresentation(trace('TIMEOUT', [postgres]), postgres).outcome).toBe('failure')
})

it('classifies span collection timeout separately from the HTTP result', () => {
it('keeps a successful HTTP result when span collection times out', () => {
const timedOut = { ...trace('SUCCESS', []), traceCollectionStatus: 'TIMED_OUT' as const }
expect(getTraceOutcome(timedOut, null)).toBe('collection_timeout')
expect(buildTraceOutcomePresentation(timedOut, null)).toMatchObject({
outcome: 'success',
resultLabel: 'HTTP 요청 성공',
collectionTimedOut: true,
})
})

it('keeps collection lifecycle labels independent from the HTTP result', () => {
expect(getTraceCollectionPresentation('streaming', 'COLLECTING', true)).toEqual({ label: '수집 중', tone: 'info' })
expect(getTraceCollectionPresentation('error', 'TIMED_OUT', true)).toEqual({ label: 'Span 수집 시간 초과', tone: 'warning' })
expect(getTraceCollectionPresentation('idle', 'COMPLETED', true)).toEqual({ label: '수집 완료', tone: 'success' })
expect(getTraceCollectionPresentation('idle', 'DISABLED', false)).toEqual({ label: 'Trace 대기', tone: 'neutral' })
})

it('selects the server span for a successful trace', () => {
Expand Down
76 changes: 71 additions & 5 deletions frontend/src/features/workbench/traceModel.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import type { GraphNodeState, TraceDetail, TraceEvent, TraceResponsePreview, TraceSummary } from '../../types/trace'
import type { StatusTone } from '../../components/StatusBadge'
import type { StreamStatus } from '../../ui/copy'

export type TraceHistoryFilter = 'all' | 'success' | 'attention' | 'timeout'
export type TraceOutcome = 'success' | 'recovered' | 'failure' | 'collection_timeout'
export type TraceOutcome = 'success' | 'recovered' | 'failure'
export const MAX_RECENT_TRACES = 25

export interface TraceOutcomePresentation {
outcome: TraceOutcome
resultLabel: string
resultDetail: string
collectionTimedOut: boolean
serviceTransitions: number
}

export interface TraceCollectionPresentation {
label: string
tone: StatusTone
}

export interface TraceMetadataItem {
key: string
label: string
Expand Down Expand Up @@ -54,10 +69,61 @@ export function formatTraceResponsePreview(preview: TraceResponsePreview | null)
return preview.body
}

export function getTraceOutcome(trace: TraceDetail, failureEvent: TraceEvent | null): TraceOutcome {
if (trace.traceCollectionStatus === 'TIMED_OUT') return 'collection_timeout'
if (trace.resultStatus === 'ERROR' || trace.resultStatus === 'TIMEOUT') return 'failure'
return failureEvent ? 'recovered' : 'success'
export function buildTraceOutcomePresentation(
trace: TraceDetail,
failureEvent: TraceEvent | null,
): TraceOutcomePresentation {
const outcome: TraceOutcome = trace.resultStatus === 'ERROR' || trace.resultStatus === 'TIMEOUT'
? 'failure'
: failureEvent ? 'recovered' : 'success'
const collectionTimedOut = trace.traceCollectionStatus === 'TIMED_OUT'
const bySpanId = new Map(trace.events.filter((event) => event.spanId).map((event) => [event.spanId as string, event]))
const serviceTransitions = trace.events.filter((event) => {
const parent = event.parentSpanId ? bySpanId.get(event.parentSpanId) : null
return Boolean(parent?.serviceName && event.serviceName && parent.serviceName !== event.serviceName)
}).length

if (outcome === 'failure') {
return {
outcome,
resultLabel: '요청 실패',
resultDetail: '실제 하위 원인 Span부터 확인하세요.',
collectionTimedOut,
serviceTransitions,
}
}
if (outcome === 'recovered') {
return {
outcome,
resultLabel: '복구된 실패',
resultDetail: '중간 오류가 발생했지만 fallback으로 요청은 완료됐습니다.',
collectionTimedOut,
serviceTransitions,
}
}
return {
outcome,
resultLabel: collectionTimedOut ? 'HTTP 요청 성공' : '정상 완료',
resultDetail: collectionTimedOut
? 'HTTP 요청은 완료됐지만 전체 Span 수집 여부는 확인할 수 없습니다.'
: '실패 없이 요청 처리가 끝났습니다.',
collectionTimedOut,
serviceTransitions,
}
}

export function getTraceCollectionPresentation(
streamStatus: StreamStatus,
collectionStatus: TraceDetail['traceCollectionStatus'],
hasTrace: boolean,
): TraceCollectionPresentation {
if (collectionStatus === 'TIMED_OUT') return { label: 'Span 수집 시간 초과', tone: 'warning' }
if (collectionStatus === 'COMPLETED' || streamStatus === 'completed') return { label: '수집 완료', tone: 'success' }
if (streamStatus === 'connection_timeout' || streamStatus === 'error') return { label: '실시간 연결 실패', tone: 'error' }
if (collectionStatus === 'COLLECTING' || streamStatus === 'streaming') return { label: '수집 중', tone: 'info' }
if (collectionStatus === 'PENDING' || streamStatus === 'connecting') return { label: 'Span 대기', tone: 'info' }
if (hasTrace) return { label: '수집 완료', tone: 'success' }
return { label: 'Trace 대기', tone: 'neutral' }
}

export function getDefaultInspectionEvent(
Expand Down
Loading
Loading