Skip to content

Commit 52b5880

Browse files
feat(observability): Prometheus metrics + OTel spans for git/sops hot path (#63)
cloneAndReadFile is on every reconcile but emitted no metrics or spans, so git-fetch and decrypt latency were invisible during incidents. Add an internal/metrics package registering custom collectors with the controller-runtime registry (exposed on the existing /metrics endpoint): - provider_kubeconfig_git_fetch_duration_seconds{repo,branch,operation,result} - provider_kubeconfig_git_cache_total{repo,branch,operation} - provider_kubeconfig_sops_decrypt_duration_seconds{format,result} - provider_kubeconfig_reconcile_errors_total{stage} (git|decrypt|secret|downstream) EnsureCloned now returns an Operation (clone|pull|revision) so the cache counter can distinguish a fresh clone from a cache-hit pull, without the git package taking an observability dependency. FormatFromPath is exported for the decrypt-format metric label. Add OpenTelemetry spans around EnsureCloned, ReadFile and SOPSDecrypt via a new internal/tracing package. Tracing is off by default and activates only when a standard OTLP endpoint is configured (OTEL_EXPORTER_OTLP_*), so behavior is unchanged out of the box; failures to init only log. Also enrich the wrapped errors on this path to carry repo URL and file path, and document metrics + tracing in the README. Closes #63 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ce1cec commit 52b5880

13 files changed

Lines changed: 386 additions & 35 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,23 @@ Git sources are cloned into a per-repo cache directory. The cache root is create
338338
| `PROVIDER_KUBECONFIG_CACHE_DIR` | `$XDG_CACHE_HOME/provider-kubeconfig` (else `$TMPDIR/provider-kubeconfig`) | Cache root. Point at a dedicated writable volume (e.g. an `emptyDir`) to keep clones off shared `/tmp`. |
339339
| `PROVIDER_KUBECONFIG_CACHE_MAX_ENTRIES` | `32` | Max cached repo directories retained before LRU eviction. |
340340

341+
## Observability
342+
343+
### Metrics
344+
345+
Custom Prometheus metrics are exposed on the manager's existing `/metrics` endpoint alongside the standard controller-runtime and crossplane metrics:
346+
347+
| Metric | Type | Labels | Description |
348+
|--------|------|--------|-------------|
349+
| `provider_kubeconfig_git_fetch_duration_seconds` | histogram | `repo`, `branch`, `operation`, `result` | Git clone/pull/revision latency. `operation` ∈ `clone\|pull\|revision`. |
350+
| `provider_kubeconfig_git_cache_total` | counter | `repo`, `branch`, `operation` | Git source operations, distinguishing fresh clone from cache-hit pull. |
351+
| `provider_kubeconfig_sops_decrypt_duration_seconds` | histogram | `format`, `result` | SOPS decrypt latency. |
352+
| `provider_kubeconfig_reconcile_errors_total` | counter | `stage` | Reconcile errors by stage (`git\|decrypt\|secret\|downstream`). |
353+
354+
### Tracing
355+
356+
The reconcile hot path emits OpenTelemetry spans (`git.EnsureCloned`, `git.ReadFile`, `sops.Decrypt`) so traces show which phase dominates. Tracing is **off by default** and activates when a standard OTLP endpoint is configured — e.g. set `OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317`. Standard `OTEL_*` env vars (headers, TLS, sampling) are honored.
357+
341358
## Building
342359

343360
### Prerequisites

cmd/provider/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import (
5151
"github.com/stuttgart-things/provider-kubeconfig/apis"
5252
kubeconfig "github.com/stuttgart-things/provider-kubeconfig/internal/controller"
5353
rbacpkg "github.com/stuttgart-things/provider-kubeconfig/internal/rbac"
54+
"github.com/stuttgart-things/provider-kubeconfig/internal/tracing"
5455
"github.com/stuttgart-things/provider-kubeconfig/internal/version"
5556
)
5657

@@ -85,6 +86,16 @@ func main() {
8586
ctrl.SetLogger(zap.New(zap.WriteTo(io.Discard)))
8687
}
8788

89+
// Optional OpenTelemetry tracing. No-op unless an OTEL endpoint is set; a
90+
// bad/unreachable endpoint must not stop the provider, so failures only log.
91+
shutdownTracing, tracingOn, err := tracing.Setup(context.Background(), version.Version)
92+
if err != nil {
93+
log.Info("OpenTelemetry tracing setup failed; continuing without tracing", "error", err)
94+
} else if tracingOn {
95+
log.Info("OpenTelemetry tracing enabled")
96+
defer func() { _ = shutdownTracing(context.Background()) }()
97+
}
98+
8899
cfg, err := ctrl.GetConfig()
89100
kingpin.FatalIfError(err, "Cannot get API server rest config")
90101

go.mod

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ require (
1717
github.com/google/go-cmp v0.7.0
1818
github.com/hashicorp/vault/api v1.23.0
1919
github.com/pkg/errors v0.9.1
20+
github.com/prometheus/client_golang v1.23.2
21+
go.opentelemetry.io/otel v1.43.0
22+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0
23+
go.opentelemetry.io/otel/sdk v1.43.0
24+
go.opentelemetry.io/otel/trace v1.43.0
2025
google.golang.org/grpc v1.81.1
2126
k8s.io/api v0.36.2
2227
k8s.io/apiextensions-apiserver v0.36.2
@@ -76,6 +81,7 @@ require (
7681
github.com/blang/semver v3.5.1+incompatible // indirect
7782
github.com/blang/semver/v4 v4.0.0 // indirect
7883
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
84+
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
7985
github.com/cespare/xxhash/v2 v2.3.0 // indirect
8086
github.com/cloudflare/circl v1.6.3 // indirect
8187
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
@@ -127,6 +133,7 @@ require (
127133
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
128134
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
129135
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect
136+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
130137
github.com/hashicorp/errwrap v1.1.0 // indirect
131138
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
132139
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -156,7 +163,6 @@ require (
156163
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
157164
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
158165
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
159-
github.com/prometheus/client_golang v1.23.2 // indirect
160166
github.com/prometheus/client_model v0.6.2 // indirect
161167
github.com/prometheus/common v0.67.5 // indirect
162168
github.com/prometheus/procfs v0.20.1 // indirect
@@ -179,11 +185,10 @@ require (
179185
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
180186
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
181187
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
182-
go.opentelemetry.io/otel v1.43.0 // indirect
188+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
183189
go.opentelemetry.io/otel/metric v1.43.0 // indirect
184-
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
185190
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
186-
go.opentelemetry.io/otel/trace v1.43.0 // indirect
191+
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
187192
go.uber.org/multierr v1.11.0 // indirect
188193
go.uber.org/zap v1.27.1 // indirect
189194
go.yaml.in/yaml/v2 v2.4.4 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,6 @@ github.com/crossplane/crossplane-runtime/v2 v2.3.2 h1:gjfJmr0PTf3/Ccg4iasogXKIRj
153153
github.com/crossplane/crossplane-runtime/v2 v2.3.2/go.mod h1:POGt8DSTcxQJlTww+3yGeeXuEdLyjZ61vZ3ap5tTxhE=
154154
github.com/crossplane/crossplane-tools v0.0.0-20251017183449-dd4517244339 h1:MPbMxSlY+82UsjrLUAGyXlh/iX1tL5WNj8W9SOaq/nk=
155155
github.com/crossplane/crossplane-tools v0.0.0-20251017183449-dd4517244339/go.mod h1:8etxwmP4cZwJDwen4+PQlnc1tggltAhEfyyigmdHulQ=
156-
github.com/crossplane/crossplane/apis/v2 v2.0.0-20260424160951-8f231230ebb6 h1:9ki6AJQgBJIcLNjK+scUZp2ZDenuAo18d0JSNOlkY2Y=
157-
github.com/crossplane/crossplane/apis/v2 v2.0.0-20260424160951-8f231230ebb6/go.mod h1:h7KE74Z4TFs1L/FFv3RdsiG9Uax7L56oHpcggSZnONg=
158156
github.com/crossplane/crossplane/apis/v2 v2.3.2 h1:Drs3xz59qT3zFfaszxQWqr51a0leAx20DBL4TqMnqi0=
159157
github.com/crossplane/crossplane/apis/v2 v2.3.2/go.mod h1:o+D0ktZQKJCFcpfzMKA4n53aTo2sFqqDsADBNIRuIyE=
160158
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=

internal/controller/remotecluster/remotecluster.go

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ import (
2121
"crypto/sha256"
2222
"encoding/json"
2323
"fmt"
24+
"time"
2425

2526
"github.com/crossplane/crossplane-runtime/v2/pkg/feature"
2627
xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2"
2728

2829
vaultapi "github.com/hashicorp/vault/api"
2930
"github.com/pkg/errors"
31+
"go.opentelemetry.io/otel"
32+
"go.opentelemetry.io/otel/attribute"
33+
"go.opentelemetry.io/otel/codes"
34+
"go.opentelemetry.io/otel/trace"
3035
corev1 "k8s.io/api/core/v1"
3136
kerrors "k8s.io/apimachinery/pkg/api/errors"
3237
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -50,9 +55,13 @@ import (
5055
clusterpkg "github.com/stuttgart-things/provider-kubeconfig/internal/cluster"
5156
decryptpkg "github.com/stuttgart-things/provider-kubeconfig/internal/decrypt"
5257
gitpkg "github.com/stuttgart-things/provider-kubeconfig/internal/git"
58+
providermetrics "github.com/stuttgart-things/provider-kubeconfig/internal/metrics"
5359
vaultpkg "github.com/stuttgart-things/provider-kubeconfig/internal/vault"
5460
)
5561

62+
// tracerName identifies the provider's spans in OpenTelemetry traces.
63+
const tracerName = "github.com/stuttgart-things/provider-kubeconfig"
64+
5665
const (
5766
errNotRemoteCluster = "managed resource is not a RemoteCluster custom resource"
5867
errTrackPCUsage = "cannot track ProviderConfig usage"
@@ -416,35 +425,82 @@ func (c *external) readFromVault(ctx context.Context, path, key string) ([]byte,
416425

417426
func (c *external) cloneAndReadFile(ctx context.Context, filePath string) ([]byte, error) {
418427
log := ctrl.LoggerFrom(ctx)
419-
420-
repo := gitpkg.NewRepo(c.providerSpec.Git.URL, c.providerSpec.Git.Branch, c.providerSpec.Git.Revision, c.gitToken)
421-
422-
if _, err := repo.EnsureCloned(ctx); err != nil {
423-
log.Info("Git clone/pull failed", "url", c.providerSpec.Git.URL, "branch", c.providerSpec.Git.Branch, "revision", c.providerSpec.Git.Revision, "error", err)
424-
return nil, errors.Wrap(err, errCloneRepo)
425-
}
426-
log.V(1).Info("Git repo ready", "url", c.providerSpec.Git.URL, "branch", c.providerSpec.Git.Branch, "revision", c.providerSpec.Git.Revision)
427-
428+
tracer := otel.Tracer(tracerName)
429+
430+
repoURL := c.providerSpec.Git.URL
431+
branch := c.providerSpec.Git.Branch
432+
revision := c.providerSpec.Git.Revision
433+
repo := gitpkg.NewRepo(repoURL, branch, revision, c.gitToken)
434+
435+
// --- git fetch (clone/pull/revision) ---
436+
fetchCtx, span := tracer.Start(ctx, "git.EnsureCloned", trace.WithAttributes(
437+
attribute.String("git.repo", repoURL),
438+
attribute.String("git.branch", branch),
439+
attribute.String("git.revision", revision),
440+
))
441+
start := time.Now()
442+
_, op, err := repo.EnsureCloned(fetchCtx)
443+
span.SetAttributes(attribute.String("git.operation", string(op)))
444+
providermetrics.GitFetchDuration.WithLabelValues(repoURL, branch, string(op), result(err)).Observe(time.Since(start).Seconds())
445+
providermetrics.GitCacheOps.WithLabelValues(repoURL, branch, string(op)).Inc()
446+
if err != nil {
447+
span.RecordError(err)
448+
span.SetStatus(codes.Error, "git fetch failed")
449+
span.End()
450+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageGit).Inc()
451+
log.Info("Git clone/pull failed", "url", repoURL, "branch", branch, "revision", revision, "error", err)
452+
return nil, errors.Wrapf(err, "%s (url=%q branch=%q revision=%q)", errCloneRepo, repoURL, branch, revision)
453+
}
454+
span.End()
455+
log.V(1).Info("Git repo ready", "url", repoURL, "branch", branch, "revision", revision, "operation", op)
456+
457+
// --- read file ---
458+
_, readSpan := tracer.Start(ctx, "git.ReadFile", trace.WithAttributes(attribute.String("git.path", filePath)))
428459
content, err := repo.ReadFile(filePath)
429460
if err != nil {
461+
readSpan.RecordError(err)
462+
readSpan.SetStatus(codes.Error, "read file failed")
463+
readSpan.End()
464+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageGit).Inc()
430465
log.Info("Failed to read file from git repo", "path", filePath, "error", err)
431-
return nil, errors.Wrap(err, errReadFile)
466+
return nil, errors.Wrapf(err, "%s (url=%q path=%q)", errReadFile, repoURL, filePath)
432467
}
468+
readSpan.End()
433469

434-
// Decrypt if an age key is available
470+
// --- decrypt (if an age key is available) ---
435471
if c.ageKey != "" {
472+
format := decryptpkg.FormatFromPath(filePath)
473+
_, decSpan := tracer.Start(ctx, "sops.Decrypt", trace.WithAttributes(
474+
attribute.String("sops.format", format),
475+
attribute.String("git.path", filePath),
476+
))
477+
start := time.Now()
436478
decrypted, err := decryptpkg.SOPSDecrypt(content, filePath, c.ageKey)
479+
providermetrics.SOPSDecryptDuration.WithLabelValues(format, result(err)).Observe(time.Since(start).Seconds())
437480
if err != nil {
481+
decSpan.RecordError(err)
482+
decSpan.SetStatus(codes.Error, "decrypt failed")
483+
decSpan.End()
484+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageDecrypt).Inc()
438485
log.Info("SOPS decryption failed", "path", filePath, "error", err)
439-
return nil, errors.Wrap(err, errDecryptFile)
486+
return nil, errors.Wrapf(err, "%s (url=%q path=%q)", errDecryptFile, repoURL, filePath)
440487
}
488+
decSpan.End()
441489
log.V(1).Info("Decrypted kubeconfig", "path", filePath)
442490
return decrypted, nil
443491
}
444492

445493
return content, nil
446494
}
447495

496+
// result maps an error to a Prometheus result label value.
497+
func result(err error) string {
498+
if err != nil {
499+
return providermetrics.ResultError
500+
}
501+
return providermetrics.ResultSuccess
502+
}
503+
448504
// buildDownstreamProviderConfig builds an unstructured downstream ProviderConfig
449505
// for provider-kubernetes or provider-helm using the resolved providerConfigMeta.
450506
func buildDownstreamProviderConfig(meta providerConfigMeta, pcName, secretName, secretNamespace, crName string) (*unstructured.Unstructured, error) {
@@ -892,12 +948,14 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext
892948
}
893949

894950
if err := c.kube.Create(ctx, secret); err != nil {
951+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageSecret).Inc()
895952
return managed.ExternalCreation{}, errors.Wrap(err, errCreateSecret)
896953
}
897954
log.Info("Created kubeconfig secret", "cluster", cr.GetName(), "secret", name, "namespace", ns)
898955

899956
// Create downstream ProviderConfigs and ArgoCD secrets
900957
if err := c.ensureDownstreamProviderConfigs(ctx, cr, name, ns, content); err != nil {
958+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageDownstream).Inc()
901959
log.Info("Failed to create downstream ProviderConfigs", "cluster", cr.GetName(), "error", err)
902960
return managed.ExternalCreation{}, err
903961
}
@@ -947,6 +1005,7 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext
9471005
// Fetch and update the existing Secret
9481006
secret := &corev1.Secret{}
9491007
if err := c.kube.Get(ctx, types.NamespacedName{Name: name, Namespace: ns}, secret); err != nil {
1008+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageSecret).Inc()
9501009
return managed.ExternalUpdate{}, errors.Wrap(err, errGetSecret)
9511010
}
9521011

@@ -958,11 +1017,13 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext
9581017
cr.Status.AtProvider.VaultSecretVersion = vaultVersion
9591018

9601019
if err := c.kube.Update(ctx, secret); err != nil {
1020+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageSecret).Inc()
9611021
return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateSecret)
9621022
}
9631023

9641024
// Reconcile downstream ProviderConfigs and ArgoCD secrets (create missing, delete stale)
9651025
if err := c.ensureDownstreamProviderConfigs(ctx, cr, name, ns, content); err != nil {
1026+
providermetrics.ReconcileErrors.WithLabelValues(providermetrics.StageDownstream).Inc()
9661027
return managed.ExternalUpdate{}, err
9671028
}
9681029

internal/decrypt/decrypt.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func SOPSDecrypt(data []byte, filePath string, ageKey string) ([]byte, error) {
5454
}
5555
defer func() { _ = os.Setenv("SOPS_AGE_KEY", prev) }()
5656

57-
format := formatFromPath(filePath)
57+
format := FormatFromPath(filePath)
5858
cleartext, err := decrypt.Data(data, format)
5959
if err != nil {
6060
return nil, errors.Wrap(err, "cannot decrypt SOPS data")
@@ -63,8 +63,8 @@ func SOPSDecrypt(data []byte, filePath string, ageKey string) ([]byte, error) {
6363
return cleartext, nil
6464
}
6565

66-
// formatFromPath returns the SOPS format string based on file extension.
67-
func formatFromPath(path string) string {
66+
// FormatFromPath returns the SOPS format string based on file extension.
67+
func FormatFromPath(path string) string {
6868
ext := strings.ToLower(filepath.Ext(path))
6969
switch ext {
7070
case ".json":

internal/decrypt/decrypt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ func TestFormatFromPath(t *testing.T) {
6666

6767
for name, tc := range cases {
6868
t.Run(name, func(t *testing.T) {
69-
got := formatFromPath(tc.path)
69+
got := FormatFromPath(tc.path)
7070
if diff := cmp.Diff(tc.want, got); diff != "" {
71-
t.Errorf("formatFromPath(%q): -want, +got:\n%s", tc.path, diff)
71+
t.Errorf("FormatFromPath(%q): -want, +got:\n%s", tc.path, diff)
7272
}
7373
})
7474
}

internal/git/git.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ func repoMutex(key string) *sync.Mutex {
164164
return mu
165165
}
166166

167+
// Operation describes which git action EnsureCloned performed, so callers can
168+
// distinguish a fresh clone from a cache-hit pull or a pinned-revision checkout
169+
// (e.g. for metrics) without importing any observability dependency here.
170+
type Operation string
171+
172+
const (
173+
// OpClone means the repo was (re)cloned from scratch.
174+
OpClone Operation = "clone"
175+
// OpPull means an existing cache was updated via pull.
176+
OpPull Operation = "pull"
177+
// OpRevision means a pinned revision was ensured (full clone or cache hit).
178+
OpRevision Operation = "revision"
179+
)
180+
167181
// Repo manages cloning and pulling a Git repository to a local cache directory.
168182
type Repo struct {
169183
url string
@@ -199,41 +213,43 @@ func (r *Repo) auth() *http.BasicAuth {
199213
// revision is pinned it delegates to ensureRevision, which checks out the exact
200214
// commit/tag instead of tracking the branch tip. On success it marks the cache
201215
// as recently used and evicts least-recently-used entries beyond the cap.
202-
func (r *Repo) EnsureCloned(ctx context.Context) (string, error) {
216+
func (r *Repo) EnsureCloned(ctx context.Context) (string, Operation, error) {
203217
mu := repoMutex(r.cacheDir)
204218
mu.Lock()
205219
defer mu.Unlock()
206220

207-
dir, err := r.ensure(ctx)
221+
dir, op, err := r.ensure(ctx)
208222
if err != nil {
209-
return "", err
223+
return "", op, err
210224
}
211225

212226
touch(dir)
213227
evictCache(filepath.Dir(r.cacheDir), r.cacheDir, maxCacheEntries())
214-
return dir, nil
228+
return dir, op, nil
215229
}
216230

217231
// ensure performs the clone/pull (or pinned-revision checkout) and returns the
218-
// cache directory, without the LRU bookkeeping handled by EnsureCloned.
219-
func (r *Repo) ensure(ctx context.Context) (string, error) {
232+
// cache directory and which Operation it performed, without the LRU bookkeeping
233+
// handled by EnsureCloned.
234+
func (r *Repo) ensure(ctx context.Context) (string, Operation, error) {
220235
if r.revision != "" {
221-
return r.ensureRevision(ctx)
236+
dir, err := r.ensureRevision(ctx)
237+
return dir, OpRevision, err
222238
}
223239

224240
refName := plumbing.NewBranchReferenceName(r.branch)
225241

226242
if _, err := os.Stat(filepath.Join(r.cacheDir, ".git")); err == nil {
227243
dir, pullErr := r.pull(ctx, refName)
228244
if pullErr == nil {
229-
return dir, nil
245+
return dir, OpPull, nil
230246
}
231247
// Pull failed (e.g. stale shallow clone) — remove cache and re-clone
232248
_ = os.RemoveAll(r.cacheDir)
233249
}
234250

235251
if err := ensureCacheRoot(filepath.Dir(r.cacheDir)); err != nil {
236-
return "", err
252+
return "", OpClone, err
237253
}
238254

239255
opts := &git.CloneOptions{
@@ -247,10 +263,10 @@ func (r *Repo) ensure(ctx context.Context) (string, error) {
247263
if _, err := git.PlainCloneContext(ctx, r.cacheDir, false, opts); err != nil {
248264
// Clean up partial clone on failure
249265
_ = os.RemoveAll(r.cacheDir)
250-
return "", errors.Wrap(err, "cannot clone git repository")
266+
return "", OpClone, errors.Wrap(err, "cannot clone git repository")
251267
}
252268

253-
return r.cacheDir, nil
269+
return r.cacheDir, OpClone, nil
254270
}
255271

256272
// ensureRevision clones the full repository (no shallow/single-branch limits, so

0 commit comments

Comments
 (0)