Skip to content

Commit 00b3060

Browse files
Add git metrics for cloning and scanning (#4234)
1 parent ead4289 commit 00b3060

File tree

2 files changed

+175
-10
lines changed

2 files changed

+175
-10
lines changed

pkg/sources/git/git.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,15 @@ type Git struct {
6464
jobID sources.JobID
6565
sourceMetadataFunc func(file, email, commit, timestamp, repository string, line int64) *source_metadatapb.MetaData
6666
verify bool
67-
metrics metrics
67+
metrics metricsCollector
6868
concurrency *semaphore.Weighted
6969
skipBinaries bool
7070
skipArchives bool
71+
repoCommitsScanned uint64 // Atomic counter for commits scanned in the current repo
7172

7273
parser *gitparse.Parser
7374
}
7475

75-
type metrics struct {
76-
commitsScanned uint64
77-
}
78-
7976
// Config for a Git source.
8077
type Config struct {
8178
Concurrency int
@@ -114,6 +111,7 @@ func NewGit(config *Config) *Git {
114111
jobID: config.JobID,
115112
sourceMetadataFunc: config.SourceMetadataFunc,
116113
verify: config.Verify,
114+
metrics: metricsInstance,
117115
concurrency: semaphore.NewWeighted(int64(config.Concurrency)),
118116
skipBinaries: config.SkipBinaries,
119117
skipArchives: config.SkipArchives,
@@ -404,6 +402,9 @@ func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, authI
404402
// DO NOT FORGET TO CLEAN UP THE CLONE PATH HERE!!
405403
// If we don't, we'll end up with a bunch of orphaned directories in the temp dir.
406404
CleanOnError(&err, clonePath)
405+
406+
// Note: We don't need to record the clone failure here as it's already
407+
// recorded in executeClone when the error occurs
407408
return "", nil, err
408409
}
409410

@@ -483,6 +484,10 @@ func executeClone(ctx context.Context, params cloneParams) (*git.Repository, err
483484
return nil, fmt.Errorf("clone command exited with no output")
484485
} else if cloneCmd.ProcessState.ExitCode() != 0 {
485486
logger.V(1).Info("git clone failed", "error", err)
487+
// Record the clone failure with the appropriate reason and exit code
488+
failureReason := ClassifyCloneError(output)
489+
exitCode := cloneCmd.ProcessState.ExitCode()
490+
metricsInstance.RecordCloneOperation(statusFailure, failureReason, exitCode)
486491
return nil, fmt.Errorf("could not clone repo: %s, %w", safeURL, err)
487492
}
488493

@@ -493,6 +498,9 @@ func executeClone(ctx context.Context, params cloneParams) (*git.Repository, err
493498
}
494499
logger.V(1).Info("successfully cloned repo")
495500

501+
// Record the successful clone operation
502+
metricsInstance.RecordCloneOperation(statusSuccess, cloneSuccess, 0)
503+
496504
return repo, nil
497505
}
498506

@@ -518,7 +526,18 @@ func PingRepoUsingToken(ctx context.Context, token, gitUrl, user string) error {
518526
fakeRef := "TRUFFLEHOG_CHECK_GIT_REMOTE_URL_REACHABILITY"
519527
gitArgs := []string{"ls-remote", lsUrl.String(), "--quiet", fakeRef}
520528
cmd := exec.Command("git", gitArgs...)
521-
_, err = cmd.CombinedOutput()
529+
output, err := cmd.CombinedOutput()
530+
531+
if err != nil {
532+
// Record the ping failure with the appropriate reason and exit code
533+
failureReason := ClassifyCloneError(string(output))
534+
exitCode := 0
535+
if cmd.ProcessState != nil {
536+
exitCode = cmd.ProcessState.ExitCode()
537+
}
538+
metricsInstance.RecordCloneOperation(statusFailure, failureReason, exitCode)
539+
}
540+
522541
return err
523542
}
524543

@@ -546,8 +565,9 @@ var codeCommitRE = regexp.MustCompile(`ssh://git-codecommit\.[\w-]+\.amazonaws\.
546565

547566
func isCodeCommitURL(gitURL string) bool { return codeCommitRE.MatchString(gitURL) }
548567

568+
// CommitsScanned returns the number of commits scanned
549569
func (s *Git) CommitsScanned() uint64 {
550-
return atomic.LoadUint64(&s.metrics.commitsScanned)
570+
return atomic.LoadUint64(&s.repoCommitsScanned)
551571
}
552572

553573
const gitDirName = ".git"
@@ -627,7 +647,9 @@ func (s *Git) ScanCommits(ctx context.Context, repo *git.Repository, path string
627647
if fullHash != lastCommitHash {
628648
depth++
629649
lastCommitHash = fullHash
630-
atomic.AddUint64(&s.metrics.commitsScanned, 1)
650+
s.metrics.RecordCommitScanned()
651+
// Increment repo-specific commit counter
652+
atomic.AddUint64(&s.repoCommitsScanned, 1)
631653
logger.V(5).Info("scanning commit", "commit", fullHash)
632654

633655
// Scan the commit metadata.
@@ -869,7 +891,9 @@ func (s *Git) ScanStaged(ctx context.Context, repo *git.Repository, path string,
869891
if fullHash != lastCommitHash {
870892
depth++
871893
lastCommitHash = fullHash
872-
atomic.AddUint64(&s.metrics.commitsScanned, 1)
894+
s.metrics.RecordCommitScanned()
895+
// Increment repo-specific commit counter
896+
atomic.AddUint64(&s.repoCommitsScanned, 1)
873897
}
874898

875899
if reachedBase && fullHash != scanOptions.BaseHash {
@@ -961,7 +985,12 @@ func (s *Git) ScanRepo(ctx context.Context, repo *git.Repository, repoPath strin
961985
}
962986
start := time.Now().Unix()
963987

988+
// Reset the repo-specific commit counter
989+
atomic.StoreUint64(&s.repoCommitsScanned, 0)
990+
964991
if err := s.ScanCommits(ctx, repo, repoPath, scanOptions, reporter); err != nil {
992+
// Record that we've failed to scan this repo
993+
s.metrics.RecordRepoScanned(statusFailure)
965994
return err
966995
}
967996
if !scanOptions.Bare {
@@ -970,6 +999,9 @@ func (s *Git) ScanRepo(ctx context.Context, repo *git.Repository, repoPath strin
970999
}
9711000
}
9721001

1002+
// Get the number of commits scanned in this repo
1003+
commitsScannedInRepo := atomic.LoadUint64(&s.repoCommitsScanned)
1004+
9731005
logger := ctx.Logger()
9741006
// We're logging time, but the repoPath is usually a dynamically generated folder in /tmp.
9751007
// To make this duration logging useful, we need to log the remote as well.
@@ -988,8 +1020,11 @@ func (s *Git) ScanRepo(ctx context.Context, repo *git.Repository, repoPath strin
9881020
"scanning git repo complete",
9891021
"path", repoPath,
9901022
"time_seconds", scanTime,
991-
"commits_scanned", atomic.LoadUint64(&s.metrics.commitsScanned),
1023+
"commits_scanned", commitsScannedInRepo,
9921024
)
1025+
1026+
// Record that we've scanned a repo successfully
1027+
s.metrics.RecordRepoScanned(statusSuccess)
9931028
return nil
9941029
}
9951030

pkg/sources/git/metrics.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/prometheus/client_golang/prometheus"
8+
"github.com/prometheus/client_golang/prometheus/promauto"
9+
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
11+
)
12+
13+
// metricsCollector defines the interface for recording Git scan metrics.
14+
type metricsCollector interface {
15+
// Clone metrics
16+
RecordCloneOperation(status string, reason string, exitCode int)
17+
18+
// Scan metrics
19+
RecordCommitScanned()
20+
RecordRepoScanned(status string)
21+
}
22+
23+
// Predefined status values
24+
const (
25+
statusSuccess = "success"
26+
statusFailure = "failure"
27+
)
28+
29+
// Predefined clone success reason
30+
const (
31+
cloneSuccess = "success"
32+
)
33+
34+
// Predefined clone failure reasons to avoid high cardinality
35+
const (
36+
// Authentication/redirection errors
37+
cloneFailureAuth = "auth_error"
38+
39+
// Rate limiting errors
40+
cloneFailureRateLimit = "rate_limit"
41+
42+
// Permission errors
43+
cloneFailurePermission = "permission_denied"
44+
45+
// Network/connection errors
46+
cloneFailureNetwork = "network_error"
47+
48+
// Git reference errors
49+
cloneFailureReference = "reference_error"
50+
51+
// Other/unknown errors
52+
cloneFailureOther = "other_error"
53+
)
54+
55+
type collector struct {
56+
cloneOperations *prometheus.CounterVec
57+
commitsScanned prometheus.Counter
58+
reposScanned *prometheus.CounterVec
59+
}
60+
61+
var metricsInstance metricsCollector
62+
63+
func init() {
64+
// These are package-level metrics that are incremented by all git scans across the lifetime of the process.
65+
metricsInstance = &collector{
66+
cloneOperations: promauto.NewCounterVec(prometheus.CounterOpts{
67+
Namespace: common.MetricsNamespace,
68+
Subsystem: common.MetricsSubsystem,
69+
Name: "git_clone_operations_total",
70+
Help: "Total number of git clone operations by status, reason, and exit code",
71+
}, []string{"status", "reason", "exit_code"}),
72+
73+
commitsScanned: promauto.NewCounter(prometheus.CounterOpts{
74+
Namespace: common.MetricsNamespace,
75+
Subsystem: common.MetricsSubsystem,
76+
Name: "git_commits_scanned_total",
77+
Help: "Total number of git commits scanned",
78+
}),
79+
80+
reposScanned: promauto.NewCounterVec(prometheus.CounterOpts{
81+
Namespace: common.MetricsNamespace,
82+
Subsystem: common.MetricsSubsystem,
83+
Name: "git_repos_scanned_total",
84+
Help: "Total number of git repositories scanned by status (success/failure)",
85+
}, []string{"status"}),
86+
}
87+
}
88+
89+
func (c *collector) RecordCloneOperation(status string, reason string, exitCode int) {
90+
c.cloneOperations.WithLabelValues(status, reason, fmt.Sprintf("%d", exitCode)).Inc()
91+
}
92+
93+
func (c *collector) RecordCommitScanned() {
94+
c.commitsScanned.Inc()
95+
}
96+
97+
func (c *collector) RecordRepoScanned(status string) {
98+
c.reposScanned.WithLabelValues(status).Inc()
99+
}
100+
101+
// ClassifyCloneError analyzes the error message and returns the appropriate failure reason
102+
func ClassifyCloneError(errMsg string) string {
103+
switch {
104+
case strings.Contains(errMsg, "unable to update url base from redirection") &&
105+
strings.Contains(errMsg, "redirect:") && strings.Contains(errMsg, "users/sign_in"):
106+
return cloneFailureAuth
107+
108+
case strings.Contains(errMsg, "The requested URL returned error: 429") ||
109+
strings.Contains(errMsg, "remote: Retry later"):
110+
return cloneFailureRateLimit
111+
112+
case strings.Contains(errMsg, "The requested URL returned error: 403") ||
113+
strings.Contains(errMsg, "remote: You are not allowed to download code from this project"):
114+
return cloneFailurePermission
115+
116+
case strings.Contains(errMsg, "RPC failed") ||
117+
strings.Contains(errMsg, "unexpected disconnect") ||
118+
strings.Contains(errMsg, "early EOF") ||
119+
strings.Contains(errMsg, "Problem (3) in the Chunked-Encoded data"):
120+
return cloneFailureNetwork
121+
122+
case strings.Contains(errMsg, "cannot process") ||
123+
strings.Contains(errMsg, "multiple updates for ref") ||
124+
strings.Contains(errMsg, "invalid index-pack output"):
125+
return cloneFailureReference
126+
127+
default:
128+
return cloneFailureOther
129+
}
130+
}

0 commit comments

Comments
 (0)