diff --git a/backend/src/main/java/com/stackflow/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/com/stackflow/backend/controller/ApiExceptionHandler.java index 4c49051..3a16a7f 100644 --- a/backend/src/main/java/com/stackflow/backend/controller/ApiExceptionHandler.java +++ b/backend/src/main/java/com/stackflow/backend/controller/ApiExceptionHandler.java @@ -12,6 +12,14 @@ @RestControllerAdvice public class ApiExceptionHandler { + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleInvalidRequest(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(Map.of( + "error", "InvalidRequest", + "message", exception.getMessage() + )); + } + @ExceptionHandler(TraceNotFoundException.class) public ResponseEntity> handleTraceNotFound(TraceNotFoundException exception) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( diff --git a/backend/src/main/java/com/stackflow/backend/controller/InstrumentationController.java b/backend/src/main/java/com/stackflow/backend/controller/InstrumentationController.java index eb0b492..f06cf38 100644 --- a/backend/src/main/java/com/stackflow/backend/controller/InstrumentationController.java +++ b/backend/src/main/java/com/stackflow/backend/controller/InstrumentationController.java @@ -3,8 +3,11 @@ import com.stackflow.backend.dto.InstrumentationProfileRequest; import com.stackflow.backend.dto.InstrumentationProfileResponse; import com.stackflow.backend.dto.InstrumentationProfileStatusResponse; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileRequest; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileResponse; import com.stackflow.backend.service.InstrumentationProfileRegistry; import com.stackflow.backend.service.SpringInstrumentationProfileService; +import com.stackflow.backend.service.SpringWorkspaceService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,13 +22,16 @@ public class InstrumentationController { private final SpringInstrumentationProfileService profileService; private final InstrumentationProfileRegistry profileRegistry; + private final SpringWorkspaceService workspaceService; public InstrumentationController( SpringInstrumentationProfileService profileService, - InstrumentationProfileRegistry profileRegistry + InstrumentationProfileRegistry profileRegistry, + SpringWorkspaceService workspaceService ) { this.profileService = profileService; this.profileRegistry = profileRegistry; + this.workspaceService = workspaceService; } @PostMapping("/profile") @@ -33,6 +39,13 @@ public InstrumentationProfileResponse createProfile(@RequestBody Instrumentation return profileService.createProfile(request); } + @PostMapping("/workspace-profile") + public WorkspaceInstrumentationProfileResponse createWorkspaceProfile( + @RequestBody WorkspaceInstrumentationProfileRequest request + ) { + return workspaceService.createProfiles(request); + } + @GetMapping("/profiles/{profileId}/status") public InstrumentationProfileStatusResponse getProfileStatus(@PathVariable String profileId) { return profileRegistry.getStatus(profileId) diff --git a/backend/src/main/java/com/stackflow/backend/controller/ProjectAnalysisController.java b/backend/src/main/java/com/stackflow/backend/controller/ProjectAnalysisController.java index c7ad8d8..7ec20b1 100644 --- a/backend/src/main/java/com/stackflow/backend/controller/ProjectAnalysisController.java +++ b/backend/src/main/java/com/stackflow/backend/controller/ProjectAnalysisController.java @@ -4,8 +4,11 @@ import com.stackflow.backend.dto.ProjectAnalyzeRequest; import com.stackflow.backend.dto.ProjectFolderSelectionResponse; import com.stackflow.backend.dto.ProjectStructureResponse; +import com.stackflow.backend.dto.WorkspaceAnalysisResponse; +import com.stackflow.backend.dto.WorkspaceAnalyzeRequest; import com.stackflow.backend.service.LocalProjectFolderPickerService; import com.stackflow.backend.service.SpringApiCatalogService; +import com.stackflow.backend.service.SpringWorkspaceService; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -19,13 +22,16 @@ public class ProjectAnalysisController { private final SpringApiCatalogService springApiCatalogService; private final LocalProjectFolderPickerService localProjectFolderPickerService; + private final SpringWorkspaceService springWorkspaceService; public ProjectAnalysisController( SpringApiCatalogService springApiCatalogService, - LocalProjectFolderPickerService localProjectFolderPickerService + LocalProjectFolderPickerService localProjectFolderPickerService, + SpringWorkspaceService springWorkspaceService ) { this.springApiCatalogService = springApiCatalogService; this.localProjectFolderPickerService = localProjectFolderPickerService; + this.springWorkspaceService = springWorkspaceService; } @GetMapping("/apis") @@ -43,6 +49,11 @@ public ProjectStructureResponse analyzeProjectStructure(@RequestBody ProjectAnal return springApiCatalogService.getProjectStructure(request.projectPath()); } + @PostMapping("/workspace/analyze") + public WorkspaceAnalysisResponse analyzeWorkspace(@RequestBody WorkspaceAnalyzeRequest request) { + return springWorkspaceService.analyze(request.workspacePath()); + } + @PostMapping("/folder/select") public ProjectFolderSelectionResponse selectProjectFolder() { return localProjectFolderPickerService.selectProjectFolder(); diff --git a/backend/src/main/java/com/stackflow/backend/domain/Trace.java b/backend/src/main/java/com/stackflow/backend/domain/Trace.java index 7018840..b09ff1e 100644 --- a/backend/src/main/java/com/stackflow/backend/domain/Trace.java +++ b/backend/src/main/java/com/stackflow/backend/domain/Trace.java @@ -16,6 +16,7 @@ public record Trace( List events, TraceSource source, String serviceName, + List serviceNames, TraceCollectionStatus traceCollectionStatus, TraceResponsePreview responsePreview ) { @@ -44,6 +45,7 @@ public Trace( events, TraceSource.SAMPLE, "stackflow-sample", + List.of("stackflow-sample"), TraceCollectionStatus.DISABLED, null ); diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalysisResponse.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalysisResponse.java new file mode 100644 index 0000000..81b5206 --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalysisResponse.java @@ -0,0 +1,10 @@ +package com.stackflow.backend.dto; + +import java.util.List; + +public record WorkspaceAnalysisResponse( + String workspaceName, + List services, + List warnings +) { +} diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalyzeRequest.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalyzeRequest.java new file mode 100644 index 0000000..17cb499 --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceAnalyzeRequest.java @@ -0,0 +1,4 @@ +package com.stackflow.backend.dto; + +public record WorkspaceAnalyzeRequest(String workspacePath) { +} diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileRequest.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileRequest.java new file mode 100644 index 0000000..1fda44e --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileRequest.java @@ -0,0 +1,8 @@ +package com.stackflow.backend.dto; + +public record WorkspaceInstrumentationProfileRequest( + String workspacePath, + String collectorBaseUrl, + String agentPath +) { +} diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileResponse.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileResponse.java new file mode 100644 index 0000000..536c7d8 --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceInstrumentationProfileResponse.java @@ -0,0 +1,9 @@ +package com.stackflow.backend.dto; + +import java.util.List; + +public record WorkspaceInstrumentationProfileResponse( + String workspaceName, + List profiles +) { +} diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceAnalysisResponse.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceAnalysisResponse.java new file mode 100644 index 0000000..00e9b63 --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceAnalysisResponse.java @@ -0,0 +1,8 @@ +package com.stackflow.backend.dto; + +public record WorkspaceServiceAnalysisResponse( + String serviceId, + String relativePath, + ProjectStructureResponse structure +) { +} diff --git a/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceProfileResponse.java b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceProfileResponse.java new file mode 100644 index 0000000..af72a0a --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/dto/WorkspaceServiceProfileResponse.java @@ -0,0 +1,9 @@ +package com.stackflow.backend.dto; + +public record WorkspaceServiceProfileResponse( + String serviceId, + String relativePath, + String workingDirectory, + InstrumentationProfileResponse profile +) { +} diff --git a/backend/src/main/java/com/stackflow/backend/service/ExternalTraceService.java b/backend/src/main/java/com/stackflow/backend/service/ExternalTraceService.java index 32e4942..a091211 100644 --- a/backend/src/main/java/com/stackflow/backend/service/ExternalTraceService.java +++ b/backend/src/main/java/com/stackflow/backend/service/ExternalTraceService.java @@ -20,6 +20,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -27,7 +28,7 @@ public class ExternalTraceService { private static final Duration COLLECTION_TIMEOUT = Duration.ofSeconds(15); - private static final Duration COMPLETION_DEBOUNCE = Duration.ofMillis(600); + private static final Duration COMPLETION_DEBOUNCE = Duration.ofSeconds(2); private final TraceService traceService; private final Map accumulators = new ConcurrentHashMap<>(); @@ -49,6 +50,10 @@ public ExternalTraceService(TraceService traceService) { this(traceService, Clock.systemUTC(), createScheduler(), COLLECTION_TIMEOUT, COMPLETION_DEBOUNCE); } + ExternalTraceService(TraceService traceService, Duration completionDebounce) { + this(traceService, Clock.systemUTC(), createScheduler(), COLLECTION_TIMEOUT, completionDebounce); + } + ExternalTraceService( TraceService traceService, Clock clock, @@ -106,7 +111,7 @@ public void recordHttpResponse( .fromBody(contentType, responseBody) .orElse(null); accumulator.httpResultRecorded = true; - if (hasServerSpan(accumulator)) { + if (hasEntryServerSpan(accumulator)) { scheduler.schedule(() -> finalizeIfQuiet(traceId), completionDebounce.toMillis(), TimeUnit.MILLISECONDS); } } @@ -125,19 +130,20 @@ public void acceptSpans(String traceId, String serviceName, List eve if (accumulators.get(traceId) != accumulator) { return; } - accumulator.serviceName = serviceName; for (TraceEvent event : events) { String key = event.spanId() == null || event.spanId().isBlank() ? event.eventId() : event.spanId(); if (accumulator.events.putIfAbsent(key, event) == null) { accepted.add(event); } } - accumulator.lastUpdatedAt = clock.instant(); - accumulator.status = TraceCollectionStatus.COLLECTING; - traceService.publishCollectionStatus(traceId, TraceCollectionStatus.COLLECTING, accepted.size() + "개 span을 수집했습니다."); - accepted.forEach(traceService::publishExternalTraceEvent); - if (hasServerSpan(accumulator)) { - scheduler.schedule(() -> finalizeIfQuiet(traceId), completionDebounce.toMillis(), TimeUnit.MILLISECONDS); + if (!accepted.isEmpty()) { + accumulator.lastUpdatedAt = clock.instant(); + accumulator.status = TraceCollectionStatus.COLLECTING; + traceService.publishCollectionStatus(traceId, TraceCollectionStatus.COLLECTING, accepted.size() + "개 span을 수집했습니다."); + accepted.forEach(traceService::publishExternalTraceEvent); + if (hasEntryServerSpan(accumulator)) { + scheduler.schedule(() -> finalizeIfQuiet(traceId), completionDebounce.toMillis(), TimeUnit.MILLISECONDS); + } } } } @@ -153,7 +159,7 @@ private void finalizeIfQuiet(String traceId) { } synchronized (accumulator) { if (Duration.between(accumulator.lastUpdatedAt, clock.instant()).compareTo(completionDebounce) < 0 - || !hasServerSpan(accumulator) + || !hasEntryServerSpan(accumulator) || !accumulator.httpResultRecorded) { return; } @@ -214,6 +220,19 @@ private Trace buildTrace(TraceAccumulator accumulator, TraceCollectionStatus col Math.max(0, Duration.between(startedAt, endedAt).toMillis()), accumulator.requestDurationMs ); + String entryServiceName = findEntryServiceName(accumulator, events); + List serviceNames = events.stream() + .map(TraceEvent::serviceName) + .filter(name -> name != null && !name.isBlank()) + .distinct() + .sorted() + .toList(); + if (serviceNames.contains(entryServiceName)) { + serviceNames = Stream.concat( + Stream.of(entryServiceName), + serviceNames.stream().filter(name -> !name.equals(entryServiceName)) + ).toList(); + } return new Trace( accumulator.traceId, accumulator.method, @@ -226,18 +245,36 @@ private Trace buildTrace(TraceAccumulator accumulator, TraceCollectionStatus col resultStatus(accumulator.httpStatus, events), events, TraceSource.OPENTELEMETRY, - accumulator.serviceName, + entryServiceName, + serviceNames, collectionStatus, accumulator.responsePreview ); } - private boolean hasServerSpan(TraceAccumulator accumulator) { + private boolean hasEntryServerSpan(TraceAccumulator accumulator) { synchronized (accumulator) { - return accumulator.events.values().stream().anyMatch(event -> "SERVER".equals(event.spanKind())); + return accumulator.events.values().stream().anyMatch(event -> + "SERVER".equals(event.spanKind()) + && accumulator.parentSpanId.equals(event.parentSpanId()) + ); } } + private String findEntryServiceName(TraceAccumulator accumulator, List events) { + return events.stream() + .filter(event -> "SERVER".equals(event.spanKind())) + .filter(event -> accumulator.parentSpanId.equals(event.parentSpanId())) + .map(TraceEvent::serviceName) + .filter(name -> name != null && !name.isBlank()) + .findFirst() + .orElseGet(() -> events.stream() + .map(TraceEvent::serviceName) + .filter(name -> name != null && !name.isBlank()) + .findFirst() + .orElse("external-spring-app")); + } + @PreDestroy void shutdown() { scheduler.shutdownNow(); @@ -255,7 +292,6 @@ private static final class TraceAccumulator { private final Map events = new LinkedHashMap<>(); private volatile TraceCollectionStatus status = TraceCollectionStatus.PENDING; private volatile Instant lastUpdatedAt; - private volatile String serviceName = "external-spring-app"; private volatile int httpStatus; private volatile long requestDurationMs; private volatile boolean httpResultRecorded; diff --git a/backend/src/main/java/com/stackflow/backend/service/SpringInstrumentationProfileService.java b/backend/src/main/java/com/stackflow/backend/service/SpringInstrumentationProfileService.java index 72911e1..ec337ba 100644 --- a/backend/src/main/java/com/stackflow/backend/service/SpringInstrumentationProfileService.java +++ b/backend/src/main/java/com/stackflow/backend/service/SpringInstrumentationProfileService.java @@ -72,7 +72,7 @@ public InstrumentationProfileResponse createProfile(InstrumentationProfileReques .map(item -> item.qualifiedName() + "[" + String.join(",", item.methods()) + "]") .reduce((left, right) -> left + ";" + right) .orElse(""); - String serviceName = toServiceName(structure.projectName()); + String serviceName = normalizeServiceName(structure.projectName()); String buildTool = detectBuildTool(projectRoot); InstrumentationProfileStatusResponse profileStatus = profileRegistry.register(serviceName); Map environment = buildEnvironment( @@ -288,7 +288,7 @@ private String detectBuildTool(Path projectRoot) { return "JAR"; } - private String toServiceName(String projectName) { + static String normalizeServiceName(String projectName) { return projectName.toLowerCase(Locale.ROOT) .replaceAll("[^a-z0-9._-]+", "-") .replaceAll("^-+|-+$", ""); diff --git a/backend/src/main/java/com/stackflow/backend/service/SpringWorkspaceService.java b/backend/src/main/java/com/stackflow/backend/service/SpringWorkspaceService.java new file mode 100644 index 0000000..f57812c --- /dev/null +++ b/backend/src/main/java/com/stackflow/backend/service/SpringWorkspaceService.java @@ -0,0 +1,198 @@ +package com.stackflow.backend.service; + +import com.stackflow.backend.dto.InstrumentationProfileRequest; +import com.stackflow.backend.dto.InstrumentationProfileResponse; +import com.stackflow.backend.dto.WorkspaceAnalysisResponse; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileRequest; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileResponse; +import com.stackflow.backend.dto.WorkspaceServiceAnalysisResponse; +import com.stackflow.backend.dto.WorkspaceServiceProfileResponse; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Stream; +import org.springframework.stereotype.Service; + +@Service +public class SpringWorkspaceService { + + private static final int MAX_SERVICES = 10; + private static final int MAX_SOURCE_DEPTH = 12; + private static final List BUILD_MARKERS = List.of( + "settings.gradle", "settings.gradle.kts", "build.gradle", "build.gradle.kts", "pom.xml", "gradlew", "mvnw" + ); + + private final SpringApiCatalogService catalogService; + private final SpringInstrumentationProfileService profileService; + + public SpringWorkspaceService( + SpringApiCatalogService catalogService, + SpringInstrumentationProfileService profileService + ) { + this.catalogService = catalogService; + this.profileService = profileService; + } + + public WorkspaceAnalysisResponse analyze(String workspacePath) { + Workspace workspace = discover(workspacePath); + List services = workspace.projects().stream() + .map(project -> new WorkspaceServiceAnalysisResponse( + project.serviceId(), + project.relativePath(), + catalogService.getProjectStructure(project.path().toString()) + )) + .toList(); + return new WorkspaceAnalysisResponse(workspace.name(), services, workspace.warnings()); + } + + public WorkspaceInstrumentationProfileResponse createProfiles(WorkspaceInstrumentationProfileRequest request) { + Workspace workspace = discover(request.workspacePath()); + List projects = workspace.projects(); + Set serviceNames = new HashSet<>(); + for (ServiceProject project : projects) { + String projectName = catalogService.getProjectStructure(project.path().toString()).projectName(); + String serviceName = SpringInstrumentationProfileService.normalizeServiceName(projectName); + if (!serviceNames.add(serviceName)) { + throw new IllegalArgumentException( + "Workspace projects resolve to the same service name: " + serviceName + ); + } + } + + List profiles = projects.stream().map(project -> { + InstrumentationProfileResponse profile = profileService.createProfile(new InstrumentationProfileRequest( + project.path().toString(), + request.collectorBaseUrl(), + request.agentPath() + )); + return new WorkspaceServiceProfileResponse( + project.serviceId(), + project.relativePath(), + project.path().toString(), + profile + ); + }).toList(); + return new WorkspaceInstrumentationProfileResponse(workspace.name(), profiles); + } + + Workspace discover(String workspacePath) { + Path workspaceRoot = resolveWorkspaceRoot(workspacePath); + Path realWorkspaceRoot; + try { + realWorkspaceRoot = workspaceRoot.toRealPath(); + } catch (IOException exception) { + throw new IllegalArgumentException("workspacePath must point to a readable directory.", exception); + } + + List warnings = new ArrayList<>(); + List projects; + if (hasBuildMarker(workspaceRoot) && hasJavaSourceRoot(workspaceRoot)) { + projects = List.of(workspaceRoot); + } else { + projects = discoverChildProjects(workspaceRoot, realWorkspaceRoot, warnings); + } + if (projects.isEmpty()) { + throw new IllegalArgumentException("No Spring Java projects were found in the workspace."); + } + if (projects.size() > MAX_SERVICES) { + throw new IllegalArgumentException("A workspace can contain at most " + MAX_SERVICES + " services."); + } + + List serviceProjects = projects.stream() + .sorted() + .map(path -> toServiceProject(workspaceRoot, path)) + .toList(); + String workspaceName = workspaceRoot.getFileName() == null + ? "workspace" + : workspaceRoot.getFileName().toString(); + return new Workspace(workspaceName, serviceProjects, List.copyOf(warnings)); + } + + private List discoverChildProjects(Path workspaceRoot, Path realWorkspaceRoot, List warnings) { + try (Stream children = Files.list(workspaceRoot)) { + return children + .filter(path -> Files.isDirectory(path) || Files.isSymbolicLink(path)) + .map(path -> validateChildProject(path, realWorkspaceRoot, warnings)) + .flatMap(java.util.Optional::stream) + .filter(this::hasBuildMarker) + .filter(this::hasJavaSourceRoot) + .sorted() + .toList(); + } catch (IOException exception) { + throw new IllegalArgumentException("Workspace entries could not be read.", exception); + } + } + + private java.util.Optional validateChildProject( + Path candidate, + Path realWorkspaceRoot, + List warnings + ) { + try { + Path realCandidate = candidate.toRealPath(); + if (!realCandidate.startsWith(realWorkspaceRoot)) { + warnings.add(candidate.getFileName() + " was ignored because it resolves outside the workspace."); + return java.util.Optional.empty(); + } + return java.util.Optional.of(realCandidate); + } catch (IOException | SecurityException exception) { + warnings.add(candidate.getFileName() + " was ignored because it could not be read."); + return java.util.Optional.empty(); + } + } + + private boolean hasBuildMarker(Path projectRoot) { + return BUILD_MARKERS.stream().anyMatch(marker -> Files.isRegularFile(projectRoot.resolve(marker))); + } + + private boolean hasJavaSourceRoot(Path projectRoot) { + try (Stream paths = Files.find( + projectRoot, + MAX_SOURCE_DEPTH, + (path, attributes) -> attributes.isDirectory() && path.endsWith(Path.of("src/main/java")) + )) { + return paths.findAny().isPresent(); + } catch (IOException | SecurityException exception) { + return false; + } + } + + private ServiceProject toServiceProject(Path workspaceRoot, Path projectPath) { + String relativePath = projectPath.equals(workspaceRoot) + ? "." + : workspaceRoot.relativize(projectPath).toString(); + String rawId = projectPath.getFileName() == null ? "service" : projectPath.getFileName().toString(); + String serviceId = rawId.toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("^-+|-+$", ""); + return new ServiceProject(serviceId.isBlank() ? "service" : serviceId, relativePath, projectPath); + } + + private Path resolveWorkspaceRoot(String workspacePath) { + if (workspacePath == null || workspacePath.isBlank()) { + throw new IllegalArgumentException("workspacePath is required."); + } + try { + Path root = Path.of(workspacePath.trim()).toAbsolutePath().normalize(); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("workspacePath must point to an existing directory."); + } + return root; + } catch (InvalidPathException exception) { + throw new IllegalArgumentException("workspacePath is invalid.", exception); + } + } + + record Workspace(String name, List projects, List warnings) { + } + + record ServiceProject(String serviceId, String relativePath, Path path) { + } +} diff --git a/backend/src/main/java/com/stackflow/backend/service/TraceSession.java b/backend/src/main/java/com/stackflow/backend/service/TraceSession.java index f121d51..5e35dfe 100644 --- a/backend/src/main/java/com/stackflow/backend/service/TraceSession.java +++ b/backend/src/main/java/com/stackflow/backend/service/TraceSession.java @@ -115,6 +115,7 @@ public Trace complete( orderedEvents, TraceSource.SAMPLE, "stackflow-sample", + List.of("stackflow-sample"), TraceCollectionStatus.DISABLED, responsePreview ); diff --git a/backend/src/test/java/com/stackflow/backend/controller/ApiExceptionHandlerTest.java b/backend/src/test/java/com/stackflow/backend/controller/ApiExceptionHandlerTest.java index 851397c..c8bc08d 100644 --- a/backend/src/test/java/com/stackflow/backend/controller/ApiExceptionHandlerTest.java +++ b/backend/src/test/java/com/stackflow/backend/controller/ApiExceptionHandlerTest.java @@ -11,6 +11,14 @@ class ApiExceptionHandlerTest { private final ApiExceptionHandler handler = new ApiExceptionHandler(); + @Test + void mapsInvalidWorkspaceRequestToBadRequest() { + assertEquals( + HttpStatus.BAD_REQUEST, + handler.handleInvalidRequest(new IllegalArgumentException("invalid workspace")).getStatusCode() + ); + } + @Test void mapsTraceSessionConflictToConflict() { assertEquals( diff --git a/backend/src/test/java/com/stackflow/backend/controller/InstrumentationControllerTest.java b/backend/src/test/java/com/stackflow/backend/controller/InstrumentationControllerTest.java index b49e9ce..c3c101e 100644 --- a/backend/src/test/java/com/stackflow/backend/controller/InstrumentationControllerTest.java +++ b/backend/src/test/java/com/stackflow/backend/controller/InstrumentationControllerTest.java @@ -14,14 +14,14 @@ class InstrumentationControllerTest { void returnsRegisteredProfileStatus() { InstrumentationProfileRegistry registry = new InstrumentationProfileRegistry(); String profileId = registry.register("order-app").profileId(); - InstrumentationController controller = new InstrumentationController(null, registry); + InstrumentationController controller = new InstrumentationController(null, registry, null); assertEquals(profileId, controller.getProfileStatus(profileId).profileId()); } @Test void returnsNotFoundForUnknownOrExpiredProfile() { - InstrumentationController controller = new InstrumentationController(null, new InstrumentationProfileRegistry()); + InstrumentationController controller = new InstrumentationController(null, new InstrumentationProfileRegistry(), null); ResponseStatusException exception = assertThrows( ResponseStatusException.class, diff --git a/backend/src/test/java/com/stackflow/backend/service/ExternalTraceServiceTest.java b/backend/src/test/java/com/stackflow/backend/service/ExternalTraceServiceTest.java index b70b881..9788339 100644 --- a/backend/src/test/java/com/stackflow/backend/service/ExternalTraceServiceTest.java +++ b/backend/src/test/java/com/stackflow/backend/service/ExternalTraceServiceTest.java @@ -22,6 +22,52 @@ class ExternalTraceServiceTest { + @Test + void keepsEntryServiceAndMergesOutOfOrderServiceBatches() throws InterruptedException { + TraceStreamService streamService = new TraceStreamService(); + TraceService traceService = new TraceService(streamService); + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + ExternalTraceService service = new ExternalTraceService( + traceService, + Clock.systemUTC(), + scheduler, + Duration.ofDays(1), + Duration.ofMillis(80) + ); + try { + ExternalTraceService.TraceCaptureContext capture = service.startCapture("GET", "/orders/1"); + Instant now = Instant.now(); + service.acceptSpans(capture.traceId(), "order-service", List.of(traceEvent( + capture, + "order-server", + capture.parentSpanId(), + "order-service", + "SERVER", + now.plusMillis(20) + ))); + service.recordHttpResponse(capture.traceId(), 200, 50); + Thread.sleep(30); + service.acceptSpans(capture.traceId(), "product-service", List.of(traceEvent( + capture, + "product-server", + "order-client", + "product-service", + "SERVER", + now + ))); + + Thread.sleep(140); + + Trace trace = traceService.getTrace(capture.traceId()); + assertEquals("order-service", trace.serviceName()); + assertEquals(List.of("order-service", "product-service"), trace.serviceNames()); + assertEquals(List.of("product-server", "order-server"), trace.events().stream().map(TraceEvent::spanId).toList()); + } finally { + service.shutdown(); + streamService.shutdown(); + } + } + @Test void waitsForHttpResponseWhenServerSpanArrivesFirst() throws InterruptedException { TraceStreamService streamService = new TraceStreamService(); @@ -50,7 +96,7 @@ void waitsForHttpResponseWhenServerSpanArrivesFirst() throws InterruptedExceptio null, Map.of(), "server-span", - null, + capture.parentSpanId(), "order-app", "SERVER" ))); @@ -186,4 +232,31 @@ void storesARequestFailureWhenNoSpansArriveBeforeTimeout() { streamService.shutdown(); } } + + private TraceEvent traceEvent( + ExternalTraceService.TraceCaptureContext capture, + String spanId, + String parentSpanId, + String serviceName, + String spanKind, + Instant startedAt + ) { + return new TraceEvent( + spanId, + capture.traceId(), + ComponentType.CONTROLLER, + spanId, + EventStatus.SUCCESS, + startedAt, + startedAt.plusMillis(10), + 10, + null, + null, + Map.of(), + spanId, + parentSpanId, + serviceName, + spanKind + ); + } } diff --git a/backend/src/test/java/com/stackflow/backend/service/OtlpTraceIngestServiceTest.java b/backend/src/test/java/com/stackflow/backend/service/OtlpTraceIngestServiceTest.java index ac809e2..869ab25 100644 --- a/backend/src/test/java/com/stackflow/backend/service/OtlpTraceIngestServiceTest.java +++ b/backend/src/test/java/com/stackflow/backend/service/OtlpTraceIngestServiceTest.java @@ -20,6 +20,7 @@ import io.opentelemetry.proto.trace.v1.Status; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.time.Duration; import java.util.HexFormat; import org.junit.jupiter.api.Test; @@ -28,7 +29,7 @@ class OtlpTraceIngestServiceTest { @Test void storesCodeLocationAndPrefersTheLatestExceptionEventWithAStackTrace() throws Exception { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); ExternalTraceService.TraceCaptureContext capture = externalTraceService.startCapture("GET", "/orders/1001"); long startNanos = Instant.now().toEpochMilli() * 1_000_000L; @@ -36,7 +37,7 @@ void storesCodeLocationAndPrefersTheLatestExceptionEventWithAStackTrace() throws try { externalTraceService.recordHttpResponse(capture.traceId(), 500, 12); Span failedSpan = Span.newBuilder(span( - capture.traceId(), "0123456789abcdef", null, "OrderService.findOrder", + capture.traceId(), "0123456789abcdef", capture.parentSpanId(), "OrderService.findOrder", Span.SpanKind.SPAN_KIND_SERVER, startNanos, 12 )) .addAttributes(attribute("code.namespace", "com.example.order.OrderService")) @@ -82,7 +83,7 @@ void storesCodeLocationAndPrefersTheLatestExceptionEventWithAStackTrace() throws @Test void limitsStackTraceAtUtf8BoundaryAndMarksItAsTruncated() throws Exception { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); ExternalTraceService.TraceCaptureContext capture = externalTraceService.startCapture("GET", "/orders/timeout"); long startNanos = Instant.now().toEpochMilli() * 1_000_000L; @@ -90,7 +91,7 @@ void limitsStackTraceAtUtf8BoundaryAndMarksItAsTruncated() throws Exception { try { externalTraceService.recordHttpResponse(capture.traceId(), 500, 12); Span failedSpan = Span.newBuilder(span( - capture.traceId(), "1123456789abcdef", null, "OrderService.timeout", + capture.traceId(), "1123456789abcdef", capture.parentSpanId(), "OrderService.timeout", Span.SpanKind.SPAN_KIND_SERVER, startNanos, 12 )) .addEvents(exceptionEvent("java.lang.RuntimeException", "실패", stackTrace)) @@ -116,7 +117,7 @@ void limitsStackTraceAtUtf8BoundaryAndMarksItAsTruncated() throws Exception { @Test void convertsOtlpSpansIntoParentChildRuntimeTrace() throws Exception { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); ExternalTraceService.TraceCaptureContext capture = externalTraceService.startCapture("GET", "/orders"); String traceId = capture.traceId(); @@ -134,6 +135,7 @@ void convertsOtlpSpansIntoParentChildRuntimeTrace() throws Exception { Span serverSpan = Span.newBuilder() .setTraceId(bytes(traceId)) .setSpanId(bytes(serverSpanId)) + .setParentSpanId(bytes(capture.parentSpanId())) .setName("GET /orders") .setKind(Span.SpanKind.SPAN_KIND_SERVER) .setStartTimeUnixNano(startNanos) @@ -184,7 +186,7 @@ void convertsOtlpSpansIntoParentChildRuntimeTrace() throws Exception { @Test void ignoresSpansForTraceIdsThatStackFlowDidNotStart() { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); String unknownTraceId = "0123456789abcdef0123456789abcdef"; long startNanos = Instant.now().toEpochMilli() * 1_000_000L; @@ -207,14 +209,14 @@ void ignoresSpansForTraceIdsThatStackFlowDidNotStart() { @Test void readsLegacySemanticKeysAndErrorTypeWithoutLeakingSensitiveMetadata() throws Exception { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); ExternalTraceService.TraceCaptureContext capture = externalTraceService.startCapture("GET", "/legacy"); long startNanos = Instant.now().toEpochMilli() * 1_000_000L; try { externalTraceService.recordHttpResponse(capture.traceId(), 504, 1_200); Span serverSpan = Span.newBuilder(span( - capture.traceId(), "5123456789abcdef", null, "GET /legacy", + capture.traceId(), "5123456789abcdef", capture.parentSpanId(), "GET /legacy", Span.SpanKind.SPAN_KIND_SERVER, startNanos, 1_200 )) .addAttributes(attribute("http.method", "GET")) @@ -250,7 +252,7 @@ void readsLegacySemanticKeysAndErrorTypeWithoutLeakingSensitiveMetadata() throws @Test void classifiesPostgresqlAndRedisDatabaseSpans() throws Exception { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, new InstrumentationProfileRegistry()); ExternalTraceService.TraceCaptureContext capture = externalTraceService.startCapture("GET", "/lab/products/1001"); String traceId = capture.traceId(); @@ -258,7 +260,7 @@ void classifiesPostgresqlAndRedisDatabaseSpans() throws Exception { long startNanos = Instant.now().toEpochMilli() * 1_000_000L; try { externalTraceService.recordHttpResponse(traceId, 200, 30); - Span serverSpan = span(traceId, serverSpanId, null, "GET /lab/products/1001", Span.SpanKind.SPAN_KIND_SERVER, startNanos, 30); + Span serverSpan = span(traceId, serverSpanId, capture.parentSpanId(), "GET /lab/products/1001", Span.SpanKind.SPAN_KIND_SERVER, startNanos, 30); Span redisSpan = Span.newBuilder(span( traceId, "2123456789abcdef", serverSpanId, "GET", Span.SpanKind.SPAN_KIND_CLIENT, startNanos + 1_000_000L, 4 )).addAttributes(attribute("db.system.name", "redis")).build(); @@ -304,7 +306,7 @@ void classifiesPostgresqlAndRedisDatabaseSpans() throws Exception { @Test void marksKnownProfileAsSeenWithoutStoringUntrackedTrace() { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); InstrumentationProfileRegistry profileRegistry = new InstrumentationProfileRegistry(); String profileId = profileRegistry.register("order-app").profileId(); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, profileRegistry); @@ -336,7 +338,7 @@ void marksKnownProfileAsSeenWithoutStoringUntrackedTrace() { @Test void ignoresUnknownProfileId() { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); InstrumentationProfileRegistry profileRegistry = new InstrumentationProfileRegistry(); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, profileRegistry); long startNanos = Instant.now().toEpochMilli() * 1_000_000L; @@ -362,7 +364,7 @@ void ignoresUnknownProfileId() { @Test void doesNotConfirmAProfileFromAnEmptyResourceBatch() { TraceService traceService = new TraceService(new TraceStreamService()); - ExternalTraceService externalTraceService = new ExternalTraceService(traceService); + ExternalTraceService externalTraceService = new ExternalTraceService(traceService, Duration.ofMillis(30)); InstrumentationProfileRegistry profileRegistry = new InstrumentationProfileRegistry(); String profileId = profileRegistry.register("order-app").profileId(); OtlpTraceIngestService ingestService = new OtlpTraceIngestService(externalTraceService, profileRegistry); diff --git a/backend/src/test/java/com/stackflow/backend/service/SpringWorkspaceServiceTest.java b/backend/src/test/java/com/stackflow/backend/service/SpringWorkspaceServiceTest.java new file mode 100644 index 0000000..05e38a0 --- /dev/null +++ b/backend/src/test/java/com/stackflow/backend/service/SpringWorkspaceServiceTest.java @@ -0,0 +1,133 @@ +package com.stackflow.backend.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.stackflow.backend.dto.WorkspaceAnalysisResponse; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileRequest; +import com.stackflow.backend.dto.WorkspaceInstrumentationProfileResponse; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SpringWorkspaceServiceTest { + + private final SpringApiCatalogService catalogService = new SpringApiCatalogService(); + private final SpringInstrumentationProfileService profileService = new SpringInstrumentationProfileService( + catalogService, + new InstrumentationProfileRegistry() + ); + private final SpringWorkspaceService workspaceService = new SpringWorkspaceService(catalogService, profileService); + + @Test + void analyzesIndependentProjectsAndCreatesProfiles(@TempDir Path workspaceRoot) throws IOException { + writeProject(workspaceRoot.resolve("order-service"), "Order", "/orders"); + writeProject(workspaceRoot.resolve("product-service"), "Product", "/products"); + + WorkspaceAnalysisResponse analysis = workspaceService.analyze(workspaceRoot.toString()); + WorkspaceInstrumentationProfileResponse profiles = workspaceService.createProfiles( + new WorkspaceInstrumentationProfileRequest( + workspaceRoot.toString(), + "http://localhost:18080", + "/tmp/opentelemetry-javaagent.jar" + ) + ); + + assertEquals("order-service", analysis.services().getFirst().serviceId()); + assertEquals("product-service", analysis.services().get(1).serviceId()); + assertEquals(1, analysis.services().getFirst().structure().analysisCoverage().detectedEndpoints()); + assertEquals(2, profiles.profiles().size()); + assertTrue(profiles.profiles().getFirst().workingDirectory().endsWith("order-service")); + assertTrue(profiles.profiles().getFirst().profile().instrumentedClasses().stream() + .anyMatch(name -> name.endsWith("OrderController"))); + } + + @Test + void fallsBackToOneProjectWhenWorkspaceItselfIsSpringProject(@TempDir Path projectRoot) throws IOException { + writeProject(projectRoot, "Catalog", "/catalog"); + + WorkspaceAnalysisResponse analysis = workspaceService.analyze(projectRoot.toString()); + + assertEquals(1, analysis.services().size()); + assertEquals(".", analysis.services().getFirst().relativePath()); + } + + @Test + void keepsAnExistingMultiModuleBuildAsOneService(@TempDir Path projectRoot) throws IOException { + Files.writeString(projectRoot.resolve("settings.gradle"), "include 'orders', 'billing'"); + writeProject(projectRoot.resolve("orders"), "Order", "/orders"); + writeProject(projectRoot.resolve("billing"), "Billing", "/billing"); + + WorkspaceAnalysisResponse analysis = workspaceService.analyze(projectRoot.toString()); + + assertEquals(1, analysis.services().size()); + assertEquals(".", analysis.services().getFirst().relativePath()); + assertEquals(2, analysis.services().getFirst().structure().analysisCoverage().detectedEndpoints()); + } + + @Test + void ignoresChildSymlinkThatEscapesWorkspace(@TempDir Path tempRoot) throws IOException { + Path workspaceRoot = tempRoot.resolve("workspace"); + Path outsideProject = tempRoot.resolve("outside-service"); + Files.createDirectories(workspaceRoot); + writeProject(workspaceRoot.resolve("order-service"), "Order", "/orders"); + writeProject(outsideProject, "Outside", "/outside"); + Files.createSymbolicLink(workspaceRoot.resolve("outside-link"), outsideProject); + + WorkspaceAnalysisResponse analysis = workspaceService.analyze(workspaceRoot.toString()); + + assertEquals(1, analysis.services().size()); + assertEquals(1, analysis.warnings().size()); + assertTrue(analysis.warnings().getFirst().contains("outside-link")); + } + + @Test + void rejectsMoreThanTenServices(@TempDir Path workspaceRoot) throws IOException { + for (int index = 0; index < 11; index++) { + writeProject(workspaceRoot.resolve("service-" + index), "Domain" + index, "/items-" + index); + } + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> workspaceService.analyze(workspaceRoot.toString()) + ); + + assertTrue(exception.getMessage().contains("at most 10")); + } + + @Test + void rejectsProfilesWithDuplicateNormalizedServiceNames(@TempDir Path workspaceRoot) throws IOException { + writeProject(workspaceRoot.resolve("order service"), "First", "/first"); + writeProject(workspaceRoot.resolve("order-service"), "Second", "/second"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> workspaceService.createProfiles(new WorkspaceInstrumentationProfileRequest( + workspaceRoot.toString(), null, null + )) + ); + + assertTrue(exception.getMessage().contains("same service name")); + } + + private void writeProject(Path projectRoot, String domain, String mapping) throws IOException { + Files.createDirectories(projectRoot); + Files.writeString(projectRoot.resolve("settings.gradle"), "rootProject.name = '" + domain.toLowerCase() + "'"); + Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'org.springframework.boot' version '4.1.0' }"); + Path packageRoot = projectRoot.resolve("src/main/java/com/example/" + domain.toLowerCase()); + Files.createDirectories(packageRoot); + Files.writeString(packageRoot.resolve(domain + "Controller.java"), """ + package com.example.%s; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RestController; + @RestController + public class %sController { + @GetMapping("%s") + public String find() { return "ok"; } + } + """.formatted(domain.toLowerCase(), domain, mapping)); + } +} diff --git a/docs/external-runtime-tracing-design.md b/docs/external-runtime-tracing-design.md index 54f648b..6cd32e8 100644 --- a/docs/external-runtime-tracing-design.md +++ b/docs/external-runtime-tracing-design.md @@ -32,6 +32,12 @@ Only public methods of analyzed Controller, Service, UseCase, Repository, Store, The Agent JAR is not downloaded automatically. Use the official OpenTelemetry Java Instrumentation release and keep the default local path or enter another path. +### Workspace Profiles + +`POST /api/project/workspace/analyze` accepts a common workspace path. StackFlow analyzes up to ten independent Gradle or Maven projects found directly below that path. If there are no independent child projects, the workspace path itself is treated as the existing single project. Child symlinks that resolve outside the workspace are ignored. + +`POST /api/instrumentation/workspace-profile` creates one existing Agent profile per detected service and returns each profile with its relative path and working directory. Normalized service names must be unique because `service.name` is the runtime boundary used in a distributed trace. + ## Correlation Contract For an external request with `captureTrace=true`, StackFlow creates: @@ -55,7 +61,9 @@ The target Java Agent continues the injected trace and exports OTLP HTTP/protobu - allowlisted HTTP, network, code, RPC, database, and OTel metadata - exception stacktrace stored separately from metadata when an OTLP span exception event provides one -Spans are deduplicated by span ID and ordered by start timestamp. A SERVER span marks the trace as eligible for completion after a short quiet period so late spans in the same export cycle can be merged. +Spans are deduplicated by span ID and ordered by start timestamp. For a captured external request, the entry SERVER span is the SERVER span whose parent is the span ID injected by StackFlow. Collection completes only after the HTTP response and entry SERVER span have both arrived and no new span has arrived for two seconds. The hard collection timeout remains 15 seconds, and timed-out traces retain spans from every service that arrived before expiry. + +`Trace.serviceName` identifies the entry service instead of whichever service exported last. `Trace.serviceNames` lists every participating service with the entry service first. ### Code Attribute Compatibility @@ -106,7 +114,7 @@ Sample traces retain the fixed StackFlow component graph. OpenTelemetry traces u ## Current Limits -- One local Spring Boot JVM. +- Workspace analysis and Agent profile generation support up to ten local Spring Boot projects; the bundled distributed runtime demo is delivered separately. - JVM restart is required; dynamic attach is not supported. - No cross-service distributed trace UI. - No OTLP Logs ingestion. Exception details are collected only from exception events attached to OTLP spans. diff --git a/frontend/src/features/workbench/traceModel.test.ts b/frontend/src/features/workbench/traceModel.test.ts index 5ab9369..e535164 100644 --- a/frontend/src/features/workbench/traceModel.test.ts +++ b/frontend/src/features/workbench/traceModel.test.ts @@ -35,7 +35,7 @@ function trace(resultStatus: EventStatus, events: TraceEvent[]): TraceDetail { traceId: 'trace', method: 'GET', endpoint: '/lab/products/1001', scenario: 'normal', startedAt: new Date(0).toISOString(), endedAt: new Date(10).toISOString(), durationMs: 10, httpStatus: resultStatus === 'ERROR' || resultStatus === 'TIMEOUT' ? 504 : 200, - resultStatus, events, source: 'OPENTELEMETRY', serviceName: 'trace-lab', traceCollectionStatus: 'COMPLETED', + resultStatus, events, source: 'OPENTELEMETRY', serviceName: 'trace-lab', serviceNames: ['trace-lab'], traceCollectionStatus: 'COMPLETED', responsePreview: null, } } diff --git a/frontend/src/features/workbench/views/traceWorkspace.test.tsx b/frontend/src/features/workbench/views/traceWorkspace.test.tsx index 2222713..a5f48c9 100644 --- a/frontend/src/features/workbench/views/traceWorkspace.test.tsx +++ b/frontend/src/features/workbench/views/traceWorkspace.test.tsx @@ -20,6 +20,7 @@ const detail: TraceDetail = { traceId: 'trace', method: 'GET', endpoint: '/lab/products/1001/database-timeout', scenario: 'timeout', startedAt: new Date(0).toISOString(), endedAt: new Date(25).toISOString(), durationMs: 25, httpStatus: 504, resultStatus: 'TIMEOUT', events: [failureEvent], source: 'OPENTELEMETRY', serviceName: 'trace-lab', + serviceNames: ['trace-lab'], traceCollectionStatus: 'COMPLETED', responsePreview: { contentType: 'application/json', body: '{"status":504}', truncated: false }, } diff --git a/frontend/src/features/workbench/workbenchModel.ts b/frontend/src/features/workbench/workbenchModel.ts index 100fb68..072a187 100644 --- a/frontend/src/features/workbench/workbenchModel.ts +++ b/frontend/src/features/workbench/workbenchModel.ts @@ -399,6 +399,7 @@ export function createPlaceholderTrace( events: [], source, serviceName, + serviceNames: serviceName ? [serviceName] : [], traceCollectionStatus: source === 'OPENTELEMETRY' ? 'PENDING' : 'DISABLED', responsePreview: null, } diff --git a/frontend/src/lib/graph.test.tsx b/frontend/src/lib/graph.test.tsx index 47d6f6f..29eee32 100644 --- a/frontend/src/lib/graph.test.tsx +++ b/frontend/src/lib/graph.test.tsx @@ -39,6 +39,7 @@ function trace(source: TraceDetail['source'], events: TraceEvent[]): TraceDetail events, source, serviceName: 'sample', + serviceNames: ['sample'], traceCollectionStatus: source === 'OPENTELEMETRY' ? 'COMPLETED' : 'DISABLED', responsePreview: null, } diff --git a/frontend/src/types/trace.ts b/frontend/src/types/trace.ts index b3ef19d..461825c 100644 --- a/frontend/src/types/trace.ts +++ b/frontend/src/types/trace.ts @@ -52,6 +52,7 @@ export interface TraceDetail { events: TraceEvent[] source: TraceSource serviceName: string | null + serviceNames: string[] traceCollectionStatus: TraceCollectionStatus responsePreview: TraceResponsePreview | null }