github.com/grafana/pyroscope@v1.18.0/cmd/profilecli/source_code_coverage.go (about) 1 package main 2 3 import ( 4 "context" 5 "fmt" 6 "net/http" 7 "os" 8 "sort" 9 "strings" 10 "time" 11 12 "github.com/go-kit/log" 13 giturl "github.com/kubescape/go-git-url" 14 "github.com/pkg/errors" 15 "golang.org/x/oauth2" 16 17 profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1" 18 "github.com/grafana/pyroscope/pkg/frontend/vcs/client" 19 "github.com/grafana/pyroscope/pkg/frontend/vcs/config" 20 "github.com/grafana/pyroscope/pkg/frontend/vcs/source" 21 "github.com/grafana/pyroscope/pkg/pprof" 22 ) 23 24 type hybridVCSClient struct { 25 configContent []byte 26 configPath string 27 realClient source.VCSClient 28 } 29 30 func (c *hybridVCSClient) GetFile(ctx context.Context, req client.FileRequest) (client.File, error) { 31 // Intercept .pyroscope.yaml requests 32 // Check if this is a request for the config file 33 if req.Path == c.configPath || 34 req.Path == config.PyroscopeConfigPath || 35 strings.HasSuffix(req.Path, ".pyroscope.yaml") || 36 strings.HasSuffix(req.Path, "/.pyroscope.yaml") { 37 // Don't need real url since this is for config file 38 url := req.Path 39 return client.File{ 40 Content: string(c.configContent), 41 URL: url, 42 }, nil 43 } 44 // Delegate to real client for actual source files 45 return c.realClient.GetFile(ctx, req) 46 } 47 48 type functionResult struct { 49 FunctionName string 50 Path string 51 Covered bool 52 Error string 53 ResolvedURL string 54 SampleCount int64 55 } 56 57 type coverageReport struct { 58 TotalFunctions int 59 CoveredFunctions int 60 UncoveredFunctions int 61 CoveragePercentage float64 62 Results []functionResult 63 } 64 65 type sourceCodeCoverageParams struct { 66 ProfilePath string 67 ConfigPath string 68 GithubToken string 69 OutputFormat string 70 ListFunctions bool 71 FunctionName string 72 TopN int 73 } 74 75 func addSourceCodeCoverageParams(cmd commander) *sourceCodeCoverageParams { 76 params := new(sourceCodeCoverageParams) 77 cmd.Flag("profile", "Path to pprof profile file").Required().StringVar(¶ms.ProfilePath) 78 cmd.Flag("config", "Path to .pyroscope.yaml file").StringVar(¶ms.ConfigPath) 79 cmd.Flag("output", "Output format: text or detailed").Default("text").StringVar(¶ms.OutputFormat) 80 cmd.Flag("list-functions", "List all functions in the profile and exit").BoolVar(¶ms.ListFunctions) 81 cmd.Flag("function", "Check coverage for a specific function (by name or path)").StringVar(¶ms.FunctionName) 82 cmd.Flag("top", "Only process the top N functions by sample count (0 = process all)").Default("0").IntVar(¶ms.TopN) 83 cmd.Flag("github-token", "GitHub token for API access").Envar(envPrefix + "GITHUB_TOKEN").StringVar(¶ms.GithubToken) 84 return params 85 } 86 87 func sourceCodeCoverage(ctx context.Context, params *sourceCodeCoverageParams) error { 88 // List functions mode 89 if params.ListFunctions { 90 return listAllFunctions(params.ProfilePath) 91 } 92 93 // Single function check mode 94 if params.FunctionName != "" { 95 if params.ConfigPath == "" { 96 return errors.New("--config is required when using --function") 97 } 98 return checkSingleFunction(ctx, params) 99 } 100 101 // Full coverage analysis mode 102 if params.ConfigPath == "" { 103 return errors.New("--config is required for full coverage analysis") 104 } 105 106 return runCoverageAnalysis(ctx, params) 107 } 108 109 func loadConfigAndProfile(configPath, profilePath string) (*config.PyroscopeConfig, []byte, *pprof.Profile, error) { 110 fmt.Fprintf(os.Stderr, "Reading configuration from %s...\n", configPath) 111 configData, err := os.ReadFile(configPath) 112 if err != nil { 113 return nil, nil, nil, errors.Wrap(err, "failed to read config file") 114 } 115 116 cfg, err := config.ParsePyroscopeConfig(configData) 117 if err != nil { 118 return nil, nil, nil, errors.Wrap(err, "failed to parse config") 119 } 120 fmt.Fprintf(os.Stderr, "✓ Loaded configuration with %d mapping(s)\n", len(cfg.SourceCode.Mappings)) 121 122 fmt.Fprintf(os.Stderr, "Reading profile from %s...\n", profilePath) 123 profile, err := pprof.OpenFile(profilePath) 124 if err != nil { 125 return nil, nil, nil, errors.Wrap(err, "failed to read profile") 126 } 127 128 return cfg, configData, profile, nil 129 } 130 131 func setupVCSClient(ctx context.Context, configData []byte, githubToken string) (source.VCSClient, *http.Client, error) { 132 fmt.Fprintf(os.Stderr, "Setting up GitHub client...\n") 133 if githubToken == "" { 134 return nil, nil, errors.New("GitHub token required (use --github-token flag or PROFILECLI_GITHUB_TOKEN env var)") 135 } 136 137 token := &oauth2.Token{AccessToken: githubToken} 138 httpClient := &http.Client{Timeout: 30 * time.Second} 139 ghClient, err := client.GithubClient(ctx, token, httpClient) 140 if err != nil { 141 return nil, nil, errors.Wrap(err, "failed to create GitHub client") 142 } 143 fmt.Fprintf(os.Stderr, "✓ GitHub client ready\n") 144 145 configPathInRepo := config.PyroscopeConfigPath 146 vcsClient := &hybridVCSClient{ 147 configContent: configData, 148 configPath: configPathInRepo, 149 realClient: ghClient, 150 } 151 152 return vcsClient, httpClient, nil 153 } 154 155 func checkFunctionCoverage(ctx context.Context, fn config.FileSpec, cfg *config.PyroscopeConfig, vcsClient source.VCSClient, httpClient *http.Client, logger log.Logger) functionResult { 156 result := functionResult{ 157 FunctionName: fn.FunctionName, 158 Path: fn.Path, 159 } 160 161 mapping := cfg.FindMapping(fn) 162 163 if mapping == nil { 164 result.Covered = false 165 result.Error = "no mapping found" 166 } else { 167 dummyRepo, _ := giturl.NewGitURL("https://github.com/dummy/repo") 168 169 finder := source.NewFileFinder( 170 vcsClient, 171 dummyRepo, 172 fn, 173 "", 174 "", 175 httpClient, 176 logger, 177 ) 178 179 response, err := finder.Find(ctx) 180 if err != nil { 181 result.Covered = false 182 result.Error = err.Error() 183 } else { 184 result.Covered = true 185 result.ResolvedURL = response.URL 186 } 187 } 188 189 return result 190 } 191 192 func runCoverageAnalysis(ctx context.Context, params *sourceCodeCoverageParams) error { 193 cfg, configData, profile, err := loadConfigAndProfile(params.ConfigPath, params.ProfilePath) 194 if err != nil { 195 return err 196 } 197 198 fmt.Fprintf(os.Stderr, "Extracting functions from profile...\n") 199 functions := extractFunctions(profile.Profile) 200 fmt.Fprintf(os.Stderr, "✓ Found %d unique function(s)\n", len(functions)) 201 202 fmt.Fprintf(os.Stderr, "Calculating sample counts and sorting functions...\n") 203 sampleCounts := calculateSampleCountsMap(profile.Profile) 204 205 type funcWithCount struct { 206 fn config.FileSpec 207 count int64 208 } 209 funcsWithCounts := make([]funcWithCount, 0, len(functions)) 210 for _, fn := range functions { 211 key := fmt.Sprintf("%s|%s", fn.FunctionName, fn.Path) 212 count := sampleCounts[key] 213 funcsWithCounts = append(funcsWithCounts, funcWithCount{fn: fn, count: count}) 214 } 215 216 // Sort by sample count in descending order 217 sort.Slice(funcsWithCounts, func(i, j int) bool { 218 return funcsWithCounts[i].count > funcsWithCounts[j].count 219 }) 220 221 if params.TopN > 0 && params.TopN < len(funcsWithCounts) { 222 funcsWithCounts = funcsWithCounts[:params.TopN] 223 fmt.Fprintf(os.Stderr, "✓ Filtered to top %d functions by sample count\n", len(funcsWithCounts)) 224 } else { 225 fmt.Fprintf(os.Stderr, "✓ Sorted %d functions by sample count\n", len(funcsWithCounts)) 226 } 227 228 functions = make([]config.FileSpec, len(funcsWithCounts)) 229 for i, fwc := range funcsWithCounts { 230 functions[i] = fwc.fn 231 } 232 233 vcsClient, httpClient, err := setupVCSClient(ctx, configData, params.GithubToken) 234 if err != nil { 235 return err 236 } 237 238 logger := log.NewNopLogger() 239 fmt.Fprintf(os.Stderr, "\nAnalyzing coverage (this may take a while)...\n") 240 report := analyzeCoverage(ctx, profile.Profile, functions, cfg, vcsClient, httpClient, logger) 241 242 fmt.Fprintf(os.Stderr, "\nGenerating report...\n") 243 return generateOutput(report, params.OutputFormat) 244 } 245 246 func extractFunctions(profile *profilev1.Profile) []config.FileSpec { 247 seen := make(map[string]bool) 248 var functions []config.FileSpec 249 250 for _, fn := range profile.Function { 251 var functionName, filePath string 252 253 if fn.Name > 0 && int(fn.Name) < len(profile.StringTable) { 254 functionName = profile.StringTable[fn.Name] 255 } 256 if fn.Filename > 0 && int(fn.Filename) < len(profile.StringTable) { 257 filePath = profile.StringTable[fn.Filename] 258 } 259 260 // Skip functions with no name or path 261 if functionName == "" && filePath == "" { 262 continue 263 } 264 265 // Create a unique key for this function 266 key := fmt.Sprintf("%s|%s", functionName, filePath) 267 if seen[key] { 268 continue 269 } 270 seen[key] = true 271 272 functions = append(functions, config.FileSpec{ 273 FunctionName: functionName, 274 Path: filePath, 275 }) 276 } 277 278 return functions 279 } 280 281 func analyzeCoverage(ctx context.Context, profile *profilev1.Profile, functions []config.FileSpec, cfg *config.PyroscopeConfig, vcsClient source.VCSClient, httpClient *http.Client, logger log.Logger) *coverageReport { 282 report := &coverageReport{ 283 TotalFunctions: len(functions), 284 Results: make([]functionResult, 0, len(functions)), 285 } 286 287 functionSampleCounts := calculateSampleCountsMap(profile) 288 289 total := len(functions) 290 for i, fn := range functions { 291 key := fmt.Sprintf("%s|%s", fn.FunctionName, fn.Path) 292 sampleCount := functionSampleCounts[key] 293 294 fmt.Fprintf(os.Stderr, "Processing function %d/%d: %s", i+1, total, fn.FunctionName) 295 if fn.Path != "" { 296 fmt.Fprintf(os.Stderr, " (%s)", fn.Path) 297 } 298 fmt.Fprintf(os.Stderr, " (samples: %d)... ", sampleCount) 299 300 result := checkFunctionCoverage(ctx, fn, cfg, vcsClient, httpClient, logger) 301 result.SampleCount = sampleCount 302 303 if result.Covered { 304 fmt.Fprintf(os.Stderr, "✓\n") 305 report.CoveredFunctions++ 306 } else { 307 fmt.Fprintf(os.Stderr, "✗\n") 308 } 309 310 report.Results = append(report.Results, result) 311 } 312 313 report.UncoveredFunctions = report.TotalFunctions - report.CoveredFunctions 314 if report.TotalFunctions > 0 { 315 report.CoveragePercentage = float64(report.CoveredFunctions) / float64(report.TotalFunctions) * 100 316 } 317 318 report.sortBySampleCount() 319 320 fmt.Fprintf(os.Stderr, "\n✓ Analysis complete: %d/%d functions covered (%.2f%%)\n", 321 report.CoveredFunctions, report.TotalFunctions, report.CoveragePercentage) 322 323 return report 324 } 325 326 func calculateSampleCountsMap(profile *profilev1.Profile) map[string]int64 { 327 functionSampleCounts := make(map[string]int64) 328 329 // Build maps for efficient lookup by ID (IDs are 1-indexed and may not be sequential) 330 locationMap := make(map[uint64]*profilev1.Location) 331 for _, loc := range profile.Location { 332 locationMap[loc.Id] = loc 333 } 334 335 functionMap := make(map[uint64]*profilev1.Function) 336 for _, fn := range profile.Function { 337 functionMap[fn.Id] = fn 338 } 339 340 // Process each sample in the profile 341 for _, sample := range profile.Sample { 342 // Sum all sample values (there can be multiple sample types) 343 var sampleValue int64 344 for _, value := range sample.Value { 345 sampleValue += value 346 } 347 348 if sampleValue == 0 { 349 continue 350 } 351 352 // Count samples for each function in the stack 353 seenFunctions := make(map[string]bool) 354 for _, locationID := range sample.LocationId { 355 location, ok := locationMap[locationID] 356 if !ok { 357 continue 358 } 359 for _, line := range location.Line { 360 if line.FunctionId == 0 { 361 continue 362 } 363 fn, ok := functionMap[line.FunctionId] 364 if !ok { 365 continue 366 } 367 368 // Extract function name and path 369 var functionName, filePath string 370 if fn.Name > 0 && int(fn.Name) < len(profile.StringTable) { 371 functionName = profile.StringTable[fn.Name] 372 } 373 if fn.Filename > 0 && int(fn.Filename) < len(profile.StringTable) { 374 filePath = profile.StringTable[fn.Filename] 375 } 376 377 // Use function key to avoid double counting in the same sample 378 key := fmt.Sprintf("%s|%s", functionName, filePath) 379 if !seenFunctions[key] { 380 functionSampleCounts[key] += sampleValue 381 seenFunctions[key] = true 382 } 383 } 384 } 385 } 386 387 return functionSampleCounts 388 } 389 390 func (r *coverageReport) sortBySampleCount() { 391 sort.Slice(r.Results, func(i, j int) bool { 392 return r.Results[i].SampleCount > r.Results[j].SampleCount 393 }) 394 } 395 396 func generateOutput(report *coverageReport, format string) error { 397 switch format { 398 case "text": 399 outputText(report) 400 case "detailed": 401 outputDetailed(report) 402 default: 403 return fmt.Errorf("unknown output format: %s", format) 404 } 405 406 return nil 407 } 408 409 func outputText(report *coverageReport) { 410 fmt.Println("=== Coverage Summary ===") 411 fmt.Printf("Total Functions: %d\n", report.TotalFunctions) 412 fmt.Printf("Covered Functions: %d\n", report.CoveredFunctions) 413 fmt.Printf("Uncovered Functions: %d\n", report.UncoveredFunctions) 414 fmt.Printf("Coverage: %.2f%%\n", report.CoveragePercentage) 415 fmt.Println() 416 } 417 418 func outputDetailed(report *coverageReport) { 419 fmt.Println("=== Detailed Results (ordered by sample count) ===") 420 fmt.Println() 421 422 // Results are already sorted by sample count in descending order 423 for _, result := range report.Results { 424 if result.Covered { 425 fmt.Printf(" ✓ %s", result.FunctionName) 426 } else { 427 fmt.Printf(" ✗ %s", result.FunctionName) 428 } 429 fmt.Printf(" (samples: %d)\n", result.SampleCount) 430 if result.Path != "" { 431 fmt.Printf(" Path: %s\n", result.Path) 432 } 433 if result.Covered { 434 if result.ResolvedURL != "" { 435 fmt.Printf(" URL: %s\n", result.ResolvedURL) 436 } 437 } else { 438 if result.Error != "" { 439 fmt.Printf(" Error: %s\n", result.Error) 440 } 441 if result.Error == "no mapping found" { 442 fmt.Printf(" No mapping found\n") 443 } 444 } 445 fmt.Println() 446 } 447 } 448 449 func listAllFunctions(profilePath string) error { 450 fmt.Fprintf(os.Stderr, "Reading profile from %s...\n", profilePath) 451 profile, err := pprof.OpenFile(profilePath) 452 if err != nil { 453 return errors.Wrap(err, "failed to read profile") 454 } 455 456 fmt.Fprintf(os.Stderr, "Extracting functions from profile...\n") 457 functions := extractFunctions(profile.Profile) 458 fmt.Fprintf(os.Stderr, "✓ Found %d unique function(s)\n\n", len(functions)) 459 460 fmt.Println("=== Functions in Profile ===") 461 fmt.Printf("Total: %d\n\n", len(functions)) 462 for i, fn := range functions { 463 fmt.Printf("%d. Function: %s\n", i+1, fn.FunctionName) 464 if fn.Path != "" { 465 fmt.Printf(" Path: %s\n", fn.Path) 466 } 467 fmt.Println() 468 } 469 470 return nil 471 } 472 473 func checkSingleFunction(ctx context.Context, params *sourceCodeCoverageParams) error { 474 cfg, configData, profile, err := loadConfigAndProfile(params.ConfigPath, params.ProfilePath) 475 if err != nil { 476 return err 477 } 478 479 fmt.Fprintf(os.Stderr, "Extracting functions from profile...\n") 480 allFunctions := extractFunctions(profile.Profile) 481 482 var matchingFunctions []config.FileSpec 483 for _, fn := range allFunctions { 484 if fn.FunctionName == params.FunctionName || fn.Path == params.FunctionName || 485 strings.Contains(fn.FunctionName, params.FunctionName) || 486 strings.Contains(fn.Path, params.FunctionName) { 487 matchingFunctions = append(matchingFunctions, fn) 488 } 489 } 490 491 if len(matchingFunctions) == 0 { 492 return fmt.Errorf("no function found matching: %s", params.FunctionName) 493 } 494 495 if len(matchingFunctions) > 1 { 496 fmt.Fprintf(os.Stderr, "⚠ Found %d matching functions, checking all of them...\n\n", len(matchingFunctions)) 497 } 498 499 vcsClient, httpClient, err := setupVCSClient(ctx, configData, params.GithubToken) 500 if err != nil { 501 return err 502 } 503 504 logger := log.NewNopLogger() 505 fmt.Fprintf(os.Stderr, "\nChecking coverage for function(s)...\n") 506 results := make([]functionResult, 0, len(matchingFunctions)) 507 508 for i, fn := range matchingFunctions { 509 if len(matchingFunctions) > 1 { 510 fmt.Fprintf(os.Stderr, "\n[%d/%d] ", i+1, len(matchingFunctions)) 511 } 512 fmt.Fprintf(os.Stderr, "Function: %s", fn.FunctionName) 513 if fn.Path != "" { 514 fmt.Fprintf(os.Stderr, " (Path: %s)", fn.Path) 515 } 516 fmt.Fprintf(os.Stderr, "... ") 517 518 result := checkFunctionCoverage(ctx, fn, cfg, vcsClient, httpClient, logger) 519 if result.Covered { 520 fmt.Fprintf(os.Stderr, "✓\n") 521 } else { 522 fmt.Fprintf(os.Stderr, "✗\n") 523 } 524 525 results = append(results, result) 526 } 527 528 fmt.Fprintf(os.Stderr, "\nGenerating report...\n\n") 529 return outputSingleFunctionResults(results) 530 } 531 532 func outputSingleFunctionResults(results []functionResult) error { 533 for i, result := range results { 534 if len(results) > 1 { 535 fmt.Printf("=== Function %d ===\n", i+1) 536 } else { 537 fmt.Println("=== Function Coverage ===") 538 } 539 fmt.Printf("Function Name: %s\n", result.FunctionName) 540 if result.Path != "" { 541 fmt.Printf("Path: %s\n", result.Path) 542 } 543 fmt.Printf("Covered: %v\n", result.Covered) 544 if result.Covered { 545 fmt.Printf("Resolved URL: %s\n", result.ResolvedURL) 546 } else { 547 if result.Error != "" { 548 fmt.Printf("Error: %s\n", result.Error) 549 } 550 } 551 if i < len(results)-1 { 552 fmt.Println() 553 } 554 } 555 return nil 556 }