Skip to content

Commit 6c8001e

Browse files
committed
fix(telemetry): skip git exec when disabled, shorten flush, clarify recorded data
- Guard getHeadSHA behind span.IsRecording() so it never execs when telemetry is off (was running git rev-parse on every repo every sync regardless) - Shorten flush timeout 3s -> 1s so an unreachable endpoint adds at most 1s on exit - Document that CLI args and action command lines are recorded; advise keeping secrets in env (never exported) - Drop redundant gitte.repo double-set; drop always-nil error return from Init
1 parent 26267d8 commit 6c8001e

6 files changed

Lines changed: 38 additions & 34 deletions

File tree

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,16 @@ See [docs/config.md](./docs/config.md) for the full configuration reference.
174174
## Telemetry
175175

176176
Gitte can export OpenTelemetry traces to an OTLP/HTTP endpoint (e.g. Elastic
177-
APM) to help debug failures. Traces capture the command run, per-repo git
178-
context (branch, commit SHA, dirty state), and per-task outcomes with errors.
179-
To identify which developer and machine hit a failure, the OS username
180-
(`user.name`) and hostname (`host.name`) are attached to every trace. Full
181-
remote URLs and command environment values are never collected.
177+
APM) to help debug failures. Traces capture per-repo git context (branch,
178+
commit SHA, dirty state) and per-task outcomes with errors. To identify which
179+
developer and machine hit a failure, the OS username (`user.name`) and hostname
180+
(`host.name`) are attached to every trace.
181+
182+
**What is recorded:** the gitte CLI arguments and each action's command line are
183+
exported as span attributes (this is intentional — knowing what ran is the
184+
point). Keep secrets out of action command definitions and CLI arguments; pass
185+
them through environment variables, which are **not** exported. Full remote
186+
URLs are never collected either (repos are identified by name only).
182187

183188
Enable it via the shared config:
184189

cmd/root.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ with dependency resolution.`,
6666

6767
// Telemetry: best-effort, never blocks. Stores root span context in globalCtx
6868
// so it propagates through the executor into gitops/actions leaf spans.
69-
shutdown, _ := telemetry.Init(globalCtx, globalCfg, cmd.Root().Version)
70-
globalTelemetryShutdown = shutdown
69+
globalTelemetryShutdown = telemetry.Init(globalCtx, globalCfg, cmd.Root().Version)
7170
globalCtx, globalRootSpan = telemetry.StartCommandSpan(globalCtx, cmd.CommandPath(), args)
7271
return nil
7372
},

gitops/gitops.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,11 @@ func syncProject(
260260
if err != nil {
261261
return err
262262
}
263-
setGitContextAttrs(span, name, currentBranch, getHeadSHA(ctx, projectPath), dirty)
263+
// Guard on IsRecording so getHeadSHA (a git exec) never runs when telemetry
264+
// is disabled — the span is non-recording and would discard the attributes.
265+
if span.IsRecording() {
266+
setGitContextAttrs(span, currentBranch, getHeadSHA(ctx, projectPath), dirty)
267+
}
264268
if dirty {
265269
setDetail("skipped")
266270
if currentBranch != defaultBranch {
@@ -423,11 +427,11 @@ func staleDays(ctx context.Context, dir, defaultBranch string) int {
423427
return 0
424428
}
425429

426-
// setGitContextAttrs records git context on a span. repo is the repo name/path
427-
// (never the full remote URL, which can embed credentials).
428-
func setGitContextAttrs(span trace.Span, repo, branch, sha string, dirty bool) {
430+
// setGitContextAttrs records git context on a span. The caller sets gitte.repo
431+
// separately (the repo name/path, never the full remote URL which can embed
432+
// credentials) so it is present on every span, including early-return paths.
433+
func setGitContextAttrs(span trace.Span, branch, sha string, dirty bool) {
429434
span.SetAttributes(
430-
attribute.String("gitte.repo", repo),
431435
attribute.String("git.branch", branch),
432436
attribute.String("git.sha", sha),
433437
attribute.Bool("git.dirty", dirty),

gitops/telemetry_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestSetGitContextAttrs(t *testing.T) {
1616
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
1717

1818
_, span := tp.Tracer("test").Start(context.Background(), "gitops.sync")
19-
setGitContextAttrs(span, "group/repo", "main", "abc123", true)
19+
setGitContextAttrs(span, "main", "abc123", true)
2020
span.End()
2121

2222
spans := exp.GetSpans()
@@ -27,8 +27,6 @@ func TestSetGitContextAttrs(t *testing.T) {
2727
dirty := false
2828
for _, kv := range spans[0].Attributes {
2929
switch kv.Key {
30-
case "gitte.repo":
31-
attrs["repo"] = kv.Value.AsString()
3230
case "git.branch":
3331
attrs["branch"] = kv.Value.AsString()
3432
case "git.sha":
@@ -37,7 +35,7 @@ func TestSetGitContextAttrs(t *testing.T) {
3735
dirty = kv.Value.AsBool()
3836
}
3937
}
40-
if attrs["repo"] != "group/repo" || attrs["branch"] != "main" || attrs["sha"] != "abc123" || !dirty {
38+
if attrs["branch"] != "main" || attrs["sha"] != "abc123" || !dirty {
4139
t.Fatalf("attrs = %+v dirty=%v", attrs, dirty)
4240
}
4341
}

telemetry/telemetry.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import (
2424

2525
const tracerName = "github.com/cego/gitte"
2626

27-
const flushTimeout = 3 * time.Second
27+
// flushTimeout bounds how long exit can block flushing spans. Kept short so an
28+
// enabled-but-unreachable endpoint (e.g. laptop with the VPN off) adds at most
29+
// this delay to every command.
30+
const flushTimeout = 1 * time.Second
2831

2932
// Resolved is the outcome of resolving telemetry settings from config + env.
3033
type Resolved struct {
@@ -92,15 +95,16 @@ func resourceAttributes(version, username, hostname string) []attribute.KeyValue
9295
return attrs
9396
}
9497

95-
// Init configures the global tracer provider. The returned shutdown function is
96-
// always non-nil and safe to call; it flushes pending spans with a bounded
97-
// timeout. Setup failures degrade to a no-op rather than returning an error.
98-
func Init(ctx context.Context, cfg *config.GitteConfig, version string) (func(), error) {
98+
// Init configures the global tracer provider and returns a shutdown function
99+
// that flushes pending spans with a bounded timeout. The returned function is
100+
// always non-nil and safe to call; setup failures and disabled telemetry both
101+
// degrade to a no-op shutdown.
102+
func Init(ctx context.Context, cfg *config.GitteConfig, version string) func() {
99103
otel.SetErrorHandler(noopErrorHandler{})
100104

101105
r := Resolve(cfg)
102106
if !r.Enabled {
103-
return func() {}, nil
107+
return func() {}
104108
}
105109

106110
var opts []otlptracehttp.Option
@@ -114,7 +118,7 @@ func Init(ctx context.Context, cfg *config.GitteConfig, version string) (func(),
114118
exporter, err := otlptracehttp.New(ctx, opts...)
115119
if err != nil {
116120
// Never block gitte: disable telemetry on exporter setup failure.
117-
return func() {}, nil
121+
return func() {}
118122
}
119123

120124
username := ""
@@ -135,7 +139,7 @@ func Init(ctx context.Context, cfg *config.GitteConfig, version string) (func(),
135139
shutdownCtx, cancel := context.WithTimeout(context.Background(), flushTimeout)
136140
defer cancel()
137141
_ = tp.Shutdown(shutdownCtx)
138-
}, nil
142+
}
139143
}
140144

141145
// Tracer returns gitte's tracer from the global provider (a no-op tracer when

telemetry/telemetry_test.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,7 @@ func TestResolve_Precedence(t *testing.T) {
104104

105105
func TestInit_DisabledReturnsNoopShutdown(t *testing.T) {
106106
t.Setenv("GITTE_TELEMETRY", "off")
107-
shutdown, err := Init(context.Background(), &config.GitteConfig{}, "test")
108-
if err != nil {
109-
t.Fatalf("unexpected error: %v", err)
110-
}
107+
shutdown := Init(context.Background(), &config.GitteConfig{}, "test")
111108
if shutdown == nil {
112109
t.Fatal("shutdown must never be nil")
113110
}
@@ -116,7 +113,7 @@ func TestInit_DisabledReturnsNoopShutdown(t *testing.T) {
116113

117114
func TestInit_EnabledReturnsCallableShutdown(t *testing.T) {
118115
// Verify that Init with a valid endpoint returns a non-nil shutdown function
119-
// that can be called without panicking or hanging (bounded 3s flush).
116+
// that can be called without panicking or hanging (bounded flush timeout).
120117
// Note: otlptracehttp.New is lazy — it accepts any URL including unreachable
121118
// endpoints without error, so Init succeeds and returns a real shutdown.
122119
// The exporter-error branch (where New returns an error and Init falls back to
@@ -126,14 +123,11 @@ func TestInit_EnabledReturnsCallableShutdown(t *testing.T) {
126123
prev := otel.GetTracerProvider()
127124
t.Cleanup(func() { otel.SetTracerProvider(prev) })
128125
cfg := &config.GitteConfig{Telemetry: config.TelemetryConfig{Endpoint: "http://localhost:4318"}}
129-
shutdown, err := Init(context.Background(), cfg, "test")
130-
if err != nil {
131-
t.Fatalf("unexpected error: %v", err)
132-
}
126+
shutdown := Init(context.Background(), cfg, "test")
133127
if shutdown == nil {
134128
t.Fatal("shutdown must never be nil on the enabled path")
135129
}
136-
shutdown() // must not panic or hang beyond the 3s flush timeout
130+
shutdown() // must not panic or hang beyond the flush timeout
137131
}
138132

139133
func TestStartCommandSpan_NoProviderDoesNotPanic(t *testing.T) {

0 commit comments

Comments
 (0)