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(&params.ProfilePath)
    78  	cmd.Flag("config", "Path to .pyroscope.yaml file").StringVar(&params.ConfigPath)
    79  	cmd.Flag("output", "Output format: text or detailed").Default("text").StringVar(&params.OutputFormat)
    80  	cmd.Flag("list-functions", "List all functions in the profile and exit").BoolVar(&params.ListFunctions)
    81  	cmd.Flag("function", "Check coverage for a specific function (by name or path)").StringVar(&params.FunctionName)
    82  	cmd.Flag("top", "Only process the top N functions by sample count (0 = process all)").Default("0").IntVar(&params.TopN)
    83  	cmd.Flag("github-token", "GitHub token for API access").Envar(envPrefix + "GITHUB_TOKEN").StringVar(&params.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  }