Skip to content

Commit 95bdfe6

Browse files
author
Yuriy Bezsonov
committed
refactor(perf-analyzer): make GitHubSourceCodeTool a singleton
1 parent e3a4426 commit 95bdfe6

2 files changed

Lines changed: 66 additions & 71 deletions

File tree

apps/perf-analyzer/src/main/java/com/example/perf/analyzer/AiService.java

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,16 @@
33
import org.slf4j.Logger;
44
import org.slf4j.LoggerFactory;
55
import org.springframework.ai.chat.client.ChatClient;
6-
import org.springframework.beans.factory.annotation.Value;
76
import org.springframework.stereotype.Service;
87

98
/**
109
* Spring AI + Amazon Bedrock Converse. Builds one {@link ChatClient}
11-
* configured with the system prompt and the always-registered
12-
* {@link PyroscopeTool}, then per-analysis layers in a fresh
13-
* {@link GitHubSourceCodeTool} when the target workload advertises a
14-
* GitHub repo via pod annotation or task tag.
10+
* configured with the system prompt and the two tools the model calls:
11+
* {@link PyroscopeTool} and {@link GitHubSourceCodeTool}.
1512
*
16-
* Per-analysis tool registration lets the analyzer serve many workloads
17-
* whose sources live in different repositories without any environment
18-
* configuration on the analyzer side.
13+
* Both tools are singletons. Per-analysis context (workload service name,
14+
* time window, repo coordinates) is communicated to the model through the
15+
* user prompt — the model passes those values back when it calls a tool.
1916
*/
2017
@Service
2118
public class AiService {
@@ -50,44 +47,33 @@ public class AiService {
5047
""";
5148

5249
private final ChatClient chatClient;
53-
private final String githubToken;
5450

5551
public AiService(
5652
ChatClient.Builder chatClientBuilder,
5753
PyroscopeTool pyroscopeTool,
58-
@Value("${GITHUB_TOKEN:}") String githubToken
54+
GitHubSourceCodeTool githubSourceCodeTool
5955
) {
60-
this.githubToken = githubToken;
6156
this.chatClient = chatClientBuilder
6257
.defaultSystem(SYSTEM_PROMPT)
63-
.defaultTools(pyroscopeTool)
58+
.defaultTools(pyroscopeTool, githubSourceCodeTool)
6459
.build();
6560
}
6661

6762
/** Runs the analysis and returns the Markdown report content. */
6863
public String analyze(AnalysisService.AnalysisContext ctx) {
69-
var sourceCodeTool = buildSourceCodeTool(ctx);
70-
var prompt = buildPrompt(ctx, sourceCodeTool != null);
64+
var hasRepo = ctx.githubRepo() != null && !ctx.githubRepo().isBlank();
65+
var prompt = buildPrompt(ctx, hasRepo);
7166

72-
logger.info("Sending analysis request to Amazon Bedrock: analysisId={} service={} sourceTool={}",
73-
ctx.analysisId(), ctx.request().service(), sourceCodeTool != null);
67+
logger.info("Sending analysis request to Amazon Bedrock: analysisId={} service={} repo={}",
68+
ctx.analysisId(), ctx.request().service(), hasRepo ? ctx.githubRepo() : "(none)");
7469

75-
var spec = chatClient.prompt().user(prompt);
76-
if (sourceCodeTool != null) {
77-
spec = spec.tools(sourceCodeTool);
78-
}
79-
var response = spec.call().content();
70+
var response = chatClient.prompt().user(prompt).call().content();
8071

8172
logger.info("Received analysis response from Amazon Bedrock: analysisId={} length={}",
8273
ctx.analysisId(), response == null ? 0 : response.length());
8374
return response == null ? "# Analysis\n\n_Model returned no content._\n" : response;
8475
}
8576

86-
private GitHubSourceCodeTool buildSourceCodeTool(AnalysisService.AnalysisContext ctx) {
87-
if (ctx.githubRepo() == null || ctx.githubRepo().isBlank()) return null;
88-
return new GitHubSourceCodeTool(ctx.githubRepo(), ctx.githubPath(), githubToken);
89-
}
90-
9177
String buildPrompt(AnalysisService.AnalysisContext ctx, boolean sourceCodeAvailable) {
9278
var r = ctx.request();
9379
var sb = new StringBuilder();
@@ -152,27 +138,36 @@ blocking waits and contention (wall view). Flag resource pressure,
152138
Be concise.
153139
""");
154140

141+
sb.append("""
142+
143+
---
144+
145+
You have a Pyroscope query tool you can invoke to request
146+
additional time windows or narrower label selectors if you need
147+
to confirm a hypothesis before writing the report.
148+
""");
149+
155150
if (sourceCodeAvailable) {
156151
sb.append("""
157152
158-
---
153+
You also have a GitHub source-code tool. Use it to look up
154+
the actual source of methods that appear in Pyroscope top
155+
functions, JFR events, or the thread dump. The target
156+
workload's source lives at:
159157
160-
You have a source code tool. Use it to look up the actual
161-
source of methods that appear in Pyroscope top functions,
162-
JFR events, or the thread dump. In your findings and
163-
recommendations, reference specific file paths, class names
164-
and line numbers. Provide concrete code fixes — show the
165-
current problematic code and the recommended replacement.
158+
""");
159+
sb.append("- repo: `").append(ctx.githubRepo()).append("`\n");
160+
var path = ctx.githubPath() == null ? "" : ctx.githubPath();
161+
sb.append("- pathPrefix: `").append(path).append("`\n\n");
162+
sb.append("""
163+
Pass those values along with the file path on every call.
164+
In your findings and recommendations, reference specific
165+
file paths, class names and line numbers. Provide concrete
166+
code fixes — show the current problematic code and the
167+
recommended replacement.
166168
""");
167169
}
168170

169-
sb.append("""
170-
171-
You also have a Pyroscope query tool you can invoke to request
172-
additional time windows or narrower label selectors if you need
173-
to confirm a hypothesis before writing the report.
174-
""");
175-
176171
return sb.toString();
177172
}
178173

apps/perf-analyzer/src/main/java/com/example/perf/analyzer/GitHubSourceCodeTool.java

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import org.slf4j.Logger;
55
import org.slf4j.LoggerFactory;
66
import org.springframework.ai.tool.annotation.Tool;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Component;
79
import org.springframework.web.client.RestClient;
810

911
import java.util.Base64;
@@ -12,66 +14,64 @@
1214
* Spring AI @Tool: fetch source code from a GitHub repository via the
1315
* REST API.
1416
*
15-
* The repo coordinates are per-analysis — the target workload advertises
16-
* them via the pod annotation {@code perf-profile/github-repo} (or the
17-
* ECS task tag {@code perf-profile:github-repo}). The analyzer constructs
18-
* a fresh instance each request, picking up whichever workload's repo
19-
* coordinates the current analysis context carries.
17+
* Singleton @Component, mirroring {@link PyroscopeTool}. Per-analysis
18+
* coordinates (repo, pathPrefix) are passed by the model on each call;
19+
* the analyzer surfaces the right values to the model through the prompt.
2020
*
21-
* Compare {@link PyroscopeTool}, which is a top-level @Component because
22-
* {@link AnalysisService} also invokes it directly for the pre-fetched
23-
* prompt sections.
21+
* The optional GitHub PAT for private repositories comes from the
22+
* GITHUB_TOKEN environment variable.
2423
*/
24+
@Component
2525
public class GitHubSourceCodeTool {
2626

2727
private static final Logger logger = LoggerFactory.getLogger(GitHubSourceCodeTool.class);
2828
private static final ObjectMapper MAPPER = new ObjectMapper();
2929

3030
private final RestClient restClient;
31-
private final String repoPath;
3231

33-
/**
34-
* @param repo "{owner}/{name}" (e.g. "aws-samples/java-on-aws")
35-
* @param path optional path-prefix inside the repo (e.g. "apps/unicorn-store-spring")
36-
* @param token optional GitHub PAT for private repos
37-
*/
38-
public GitHubSourceCodeTool(String repo, String path, String token) {
39-
if (repo == null || repo.isBlank()) {
40-
throw new IllegalArgumentException("repo must not be blank");
41-
}
32+
public GitHubSourceCodeTool(@Value("${GITHUB_TOKEN:}") String githubToken) {
4233
var builder = RestClient.builder()
43-
.baseUrl("https://api.github.com/repos/" + repo.replaceAll("/$", ""))
34+
.baseUrl("https://api.github.com")
4435
.defaultHeader("Accept", "application/vnd.github.v3+json")
4536
.defaultHeader("User-Agent", "perf-analyzer");
46-
if (token != null && !token.isBlank()) {
47-
builder.defaultHeader("Authorization", "token " + token);
37+
if (githubToken != null && !githubToken.isBlank()) {
38+
builder.defaultHeader("Authorization", "token " + githubToken);
4839
}
4940
this.restClient = builder.build();
50-
this.repoPath = (path != null && !path.isBlank())
51-
? path.replaceAll("/$", "") : "";
5241
}
5342

5443
@Tool(description = """
55-
Fetch a source code file from the application GitHub repository.
56-
Provide the path relative to the application root, e.g.
57-
src/main/java/com/unicorn/store/service/UnicornService.java — the
58-
repository base path is prepended automatically.
44+
Fetch a source code file from a GitHub repository.
45+
Parameters:
46+
repo - "{owner}/{name}" (e.g. "aws-samples/java-on-aws").
47+
pathPrefix - optional path prefix inside the repo for the
48+
application root (e.g. "apps/unicorn-store-spring").
49+
Pass an empty string if not applicable.
50+
filePath - path relative to the application root, e.g.
51+
"src/main/java/com/unicorn/store/service/UnicornService.java".
5952
Use this to look up Java source files referenced in stack traces,
6053
thread dumps, and JFR event summaries so recommendations can cite
6154
file paths and line numbers.
6255
""")
63-
public String fetchSourceCode(String filePath) {
64-
var fullPath = repoPath.isEmpty() ? filePath : repoPath + "/" + filePath;
56+
public String fetchSourceCode(String repo, String pathPrefix, String filePath) {
57+
if (repo == null || repo.isBlank()) {
58+
return "Source code not available: repo not provided.";
59+
}
60+
var prefix = (pathPrefix == null || pathPrefix.isBlank())
61+
? "" : pathPrefix.replaceAll("/$", "");
62+
var fullPath = prefix.isEmpty() ? filePath : prefix + "/" + filePath;
63+
var uri = "/repos/" + repo.replaceAll("/$", "") + "/contents/" + fullPath;
6564
try {
6665
var json = restClient.get()
67-
.uri("/contents/{path}", fullPath)
66+
.uri(uri)
6867
.retrieve()
6968
.body(String.class);
7069
var node = MAPPER.readTree(json);
7170
var encoded = node.get("content").asText();
7271
return new String(Base64.getMimeDecoder().decode(encoded));
7372
} catch (Exception e) {
74-
logger.warn("Failed to fetch source code for {}: {}", fullPath, e.getMessage());
73+
logger.warn("Failed to fetch source code repo={} path={}: {}",
74+
repo, fullPath, e.getMessage());
7575
return "Source code not available: " + e.getMessage();
7676
}
7777
}

0 commit comments

Comments
 (0)