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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
@RestControllerAdvice
public class ApiExceptionHandler {

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleInvalidRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of(
"error", "InvalidRequest",
"message", exception.getMessage()
));
}

@ExceptionHandler(TraceNotFoundException.class)
public ResponseEntity<Map<String, String>> handleTraceNotFound(TraceNotFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,20 +22,30 @@ 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")
public InstrumentationProfileResponse createProfile(@RequestBody InstrumentationProfileRequest request) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/java/com/stackflow/backend/domain/Trace.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public record Trace(
List<TraceEvent> events,
TraceSource source,
String serviceName,
List<String> serviceNames,
TraceCollectionStatus traceCollectionStatus,
TraceResponsePreview responsePreview
) {
Expand Down Expand Up @@ -44,6 +45,7 @@ public Trace(
events,
TraceSource.SAMPLE,
"stackflow-sample",
List.of("stackflow-sample"),
TraceCollectionStatus.DISABLED,
null
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stackflow.backend.dto;

import java.util.List;

public record WorkspaceAnalysisResponse(
String workspaceName,
List<WorkspaceServiceAnalysisResponse> services,
List<String> warnings
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.stackflow.backend.dto;

public record WorkspaceAnalyzeRequest(String workspacePath) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.stackflow.backend.dto;

public record WorkspaceInstrumentationProfileRequest(
String workspacePath,
String collectorBaseUrl,
String agentPath
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stackflow.backend.dto;

import java.util.List;

public record WorkspaceInstrumentationProfileResponse(
String workspaceName,
List<WorkspaceServiceProfileResponse> profiles
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.stackflow.backend.dto;

public record WorkspaceServiceAnalysisResponse(
String serviceId,
String relativePath,
ProjectStructureResponse structure
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stackflow.backend.dto;

public record WorkspaceServiceProfileResponse(
String serviceId,
String relativePath,
String workingDirectory,
InstrumentationProfileResponse profile
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
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;

@Service
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<String, TraceAccumulator> accumulators = new ConcurrentHashMap<>();
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -125,19 +130,20 @@ public void acceptSpans(String traceId, String serviceName, List<TraceEvent> 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);
}
}
}
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<String> 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,
Expand All @@ -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<TraceEvent> 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();
Expand All @@ -255,7 +292,6 @@ private static final class TraceAccumulator {
private final Map<String, TraceEvent> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> environment = buildEnvironment(
Expand Down Expand Up @@ -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("^-+|-+$", "");
Expand Down
Loading
Loading