Skip to content

Commit 5122796

Browse files
committed
fix(telemetry): harden export lifecycle and log correlation
1 parent 0fa6e11 commit 5122796

14 files changed

Lines changed: 432 additions & 155 deletions

File tree

actions/runner.go

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ func RunActions(ctx context.Context, cfg *config.GitteConfig, st *state.GitteSta
4040
view := newView(mode, infos, actionOrder, runCancel, retryCh, cfg.QuickSolve.GitClean.Exclude)
4141

4242
tracker := telemetry.NewActionTracker(ctx)
43-
reg := telemetry.NewSpanRegistry()
4443

4544
onStart := func(name string) {
4645
tracker.OnStart(name)
@@ -72,7 +71,7 @@ func RunActions(ctx context.Context, cfg *config.GitteConfig, st *state.GitteSta
7271
var retrySet map[string]struct{} // nil on first run
7372
var runErr error
7473
for {
75-
tasks := buildExecutorTasks(cfg, st, cwd, keys, tracker, reg)
74+
tasks := buildExecutorTasks(cfg, st, cwd, keys, tracker)
7675

7776
// Strip needs from explicitly retried tasks so they run immediately.
7877
if retrySet != nil {
@@ -114,7 +113,7 @@ func RunActions(ctx context.Context, cfg *config.GitteConfig, st *state.GitteSta
114113
exec.WithPreCompleted(succeeded, failed)
115114
}
116115

117-
exec.WithOutputHandler(telemetry.LogOutputHandler(view.Handler(), reg))
116+
exec.WithOutputHandler(view.Handler())
118117
exec.WithRetryChannel(retryCh)
119118

120119
runErr = exec.Execute(runCtx)
@@ -208,7 +207,7 @@ func buildTaskInfos(cfg *config.GitteConfig, st *state.GitteState, cwd string, k
208207
}
209208

210209
// buildExecutorTasks constructs executor.Task list from keys.
211-
func buildExecutorTasks(cfg *config.GitteConfig, st *state.GitteState, cwd string, keys []GroupKeyWithDeps, tracker *telemetry.ActionTracker, reg *telemetry.SpanRegistry) []executor.Task {
210+
func buildExecutorTasks(cfg *config.GitteConfig, st *state.GitteState, cwd string, keys []GroupKeyWithDeps, tracker *telemetry.ActionTracker) []executor.Task {
212211
tasks := make([]executor.Task, 0, len(keys))
213212
searchFors := cfg.SearchFor
214213

@@ -253,7 +252,7 @@ func buildExecutorTasks(cfg *config.GitteConfig, st *state.GitteState, cwd strin
253252
Needs: needNames,
254253
Retry: retryConfig,
255254
ExecuteFn: func(ctx context.Context, tName string, handler executor.OutputHandler) error {
256-
return runGroupTask(ctx, cfg, st, cwd, proj, key.Project, tName, cmds, allSearchFors, handler, tracker, reg)
255+
return runGroupTask(ctx, cfg, st, cwd, proj, key.Project, tName, cmds, allSearchFors, handler, tracker)
257256
},
258257
})
259258
}
@@ -285,38 +284,28 @@ func runGroupTask(
285284
searchFors []config.SearchFor,
286285
handler executor.OutputHandler,
287286
tracker *telemetry.ActionTracker,
288-
reg *telemetry.SpanRegistry,
289287
) (err error) {
290288
actionCtx := tracker.ActionContext(telemetry.ActionOf(taskName))
291289
// Parent the task span under the action span, but keep running under the
292290
// executor's incoming (cancellable) context so cancellation still propagates
293291
// to the command — attach the span to ctx rather than replacing ctx.
294292
_, span := telemetry.Tracer().Start(actionCtx, "action.run "+taskName)
295293
ctx = trace.ContextWithSpan(ctx, span)
296-
reg.Set(taskName, span.SpanContext())
297-
setActionAttrs(span, taskName, projName, strings.Join(cmds, " "))
298-
if feats := enabledFeaturesForProject(cfg, st, projName, proj); len(feats) > 0 {
299-
span.SetAttributes(attribute.StringSlice("gitte.features", feats))
300-
}
301-
if env := injectedEnv(cfg, st, projName, proj); len(env) > 0 {
302-
keys := make([]string, 0, len(env))
303-
for k := range env {
304-
keys = append(keys, k)
305-
}
306-
sort.Strings(keys)
307-
kvs := make([]string, 0, len(keys))
308-
for _, k := range keys {
309-
kvs = append(kvs, k+"="+env[k])
310-
}
311-
span.SetAttributes(attribute.StringSlice("gitte.env", kvs))
312-
}
294+
handler = telemetry.LogOutputHandler(handler)
295+
setTaskTelemetryAttrs(span, cfg, st, projName, proj, taskName, cmds)
313296
defer func() {
297+
if recovered := recover(); recovered != nil {
298+
panicErr := fmt.Errorf("panic: %v", recovered)
299+
span.RecordError(panicErr)
300+
span.SetStatus(codes.Error, panicErr.Error())
301+
span.End()
302+
panic(recovered)
303+
}
314304
if err != nil {
315305
span.RecordError(err)
316306
span.SetStatus(codes.Error, err.Error())
317307
}
318308
span.End()
319-
reg.Delete(taskName)
320309
}()
321310

322311
if len(cmds) == 0 {
@@ -348,18 +337,51 @@ func runGroupTask(
348337
return err
349338
}
350339

351-
span.SetAttributes(attribute.Int("gitte.exit_code", res.ExitCode))
340+
if span.IsRecording() {
341+
span.SetAttributes(attribute.Int("gitte.exit_code", res.ExitCode))
342+
if res.ExitCode != 0 {
343+
if tail := outputTail(res.Stderr, res.Stdout); tail != "" {
344+
span.SetAttributes(attribute.String("gitte.error_tail", tail))
345+
}
346+
}
347+
}
352348

353349
if res.ExitCode != 0 {
354-
if tail := outputTail(res.Stderr, res.Stdout); tail != "" {
355-
span.SetAttributes(attribute.String("gitte.error_tail", tail))
356-
}
357350
return fmt.Errorf("command exited with code %d", res.ExitCode)
358351
}
359352

360353
return nil
361354
}
362355

356+
func setTaskTelemetryAttrs(
357+
span trace.Span,
358+
cfg *config.GitteConfig,
359+
st *state.GitteState,
360+
projName string,
361+
proj config.ProjectConfig,
362+
taskName string,
363+
cmds []string,
364+
) {
365+
if span.IsRecording() {
366+
setActionAttrs(span, taskName, projName, strings.Join(cmds, " "))
367+
if feats := enabledFeaturesForProject(cfg, st, projName, proj); len(feats) > 0 {
368+
span.SetAttributes(attribute.StringSlice("gitte.features", feats))
369+
}
370+
if env := injectedEnv(cfg, st, projName, proj); len(env) > 0 {
371+
keys := make([]string, 0, len(env))
372+
for k := range env {
373+
keys = append(keys, k)
374+
}
375+
sort.Strings(keys)
376+
kvs := make([]string, 0, len(keys))
377+
for _, k := range keys {
378+
kvs = append(kvs, k+"="+env[k])
379+
}
380+
span.SetAttributes(attribute.StringSlice("gitte.env", kvs))
381+
}
382+
}
383+
}
384+
363385
// errorTailBytes caps how much trailing command output is attached to a failed
364386
// task span via the gitte.error_tail attribute.
365387
const errorTailBytes = 4096

actions/telemetry_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"context"
55
"testing"
66

7+
"github.com/cego/gitte/config"
78
"go.opentelemetry.io/otel"
89
sdktrace "go.opentelemetry.io/otel/sdk/trace"
910
"go.opentelemetry.io/otel/sdk/trace/tracetest"
11+
"go.opentelemetry.io/otel/trace/noop"
1012
)
1113

1214
func TestSetActionAttrs(t *testing.T) {
@@ -35,3 +37,10 @@ func TestSetActionAttrs(t *testing.T) {
3537
t.Fatalf("attrs = %+v", got)
3638
}
3739
}
40+
41+
func TestSetTaskTelemetryAttrs_SkipsWorkForNonRecordingSpan(t *testing.T) {
42+
_, span := noop.NewTracerProvider().Tracer("test").Start(context.Background(), "task")
43+
// nil config/state would panic in feature and environment resolution. A
44+
// non-recording span must return before touching either dependency.
45+
setTaskTelemetryAttrs(span, nil, nil, "project", config.ProjectConfig{}, "project:up:default", []string{"true"})
46+
}

cmd/root.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ var (
3636
globalCtx context.Context
3737
globalCancel context.CancelFunc
3838

39-
globalTelemetryShutdown func()
39+
globalTelemetryShutdown func(context.Context)
4040
globalRootSpan trace.Span
4141
)
4242

@@ -86,8 +86,7 @@ func Execute() {
8686
globalCancel()
8787
}
8888
}()
89-
err := rootCmd.Execute()
90-
finishTelemetry(err)
89+
err := executeRoot()
9190
if err != nil {
9291
if output.DetectMode(flagNoTTY) == output.ModePlain {
9392
fmt.Fprintln(os.Stderr, "error:", err)
@@ -98,6 +97,25 @@ func Execute() {
9897
}
9998
}
10099

100+
// executeRoot guarantees telemetry finalization for both returned errors and
101+
// panics. A panic is recorded as an error before being re-thrown so callers keep
102+
// the normal panic behavior and stack output.
103+
func executeRoot() (err error) {
104+
return runWithTelemetry(rootCmd.Execute)
105+
}
106+
107+
func runWithTelemetry(run func() error) (err error) {
108+
defer func() {
109+
if recovered := recover(); recovered != nil {
110+
panicErr := fmt.Errorf("panic: %v", recovered)
111+
finishTelemetry(panicErr)
112+
panic(recovered)
113+
}
114+
finishTelemetry(err)
115+
}()
116+
return run()
117+
}
118+
101119
// finishTelemetry records the final command status on the root span and flushes
102120
// pending spans. Safe to call when telemetry was never initialized (e.g.
103121
// completion commands or an early config failure), where the handles remain nil.
@@ -112,7 +130,9 @@ func finishTelemetry(err error) {
112130
globalRootSpan.End()
113131
}
114132
if globalTelemetryShutdown != nil {
115-
globalTelemetryShutdown()
133+
shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
134+
defer stop()
135+
globalTelemetryShutdown(shutdownCtx)
116136
}
117137
}
118138

cmd/root_telemetry_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"go.opentelemetry.io/otel/codes"
8+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
9+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
10+
)
11+
12+
func TestRunWithTelemetry_RecordsAndFlushesPanicThenRepanics(t *testing.T) {
13+
exporter := tracetest.NewInMemoryExporter()
14+
provider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
15+
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
16+
17+
_, span := provider.Tracer("test").Start(context.Background(), "gitte test")
18+
previousSpan := globalRootSpan
19+
previousShutdown := globalTelemetryShutdown
20+
globalRootSpan = span
21+
shutdownCalled := false
22+
globalTelemetryShutdown = func(context.Context) { shutdownCalled = true }
23+
t.Cleanup(func() {
24+
globalRootSpan = previousSpan
25+
globalTelemetryShutdown = previousShutdown
26+
})
27+
28+
var recovered any
29+
func() {
30+
defer func() { recovered = recover() }()
31+
_ = runWithTelemetry(func() error { panic("boom") })
32+
}()
33+
34+
if recovered != "boom" {
35+
t.Fatalf("recovered panic = %v, want boom", recovered)
36+
}
37+
if !shutdownCalled {
38+
t.Fatal("telemetry shutdown was not called")
39+
}
40+
spans := exporter.GetSpans()
41+
if len(spans) != 1 {
42+
t.Fatalf("exported %d spans, want 1", len(spans))
43+
}
44+
if spans[0].Status.Code != codes.Error {
45+
t.Fatalf("root span status = %v, want error", spans[0].Status.Code)
46+
}
47+
foundException := false
48+
for _, event := range spans[0].Events {
49+
if event.Name == "exception" {
50+
foundException = true
51+
}
52+
}
53+
if !foundException {
54+
t.Fatal("panic was not sent as an exception event")
55+
}
56+
}

config/startup_checks.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7+
"io"
78
"os"
89
"os/exec"
910
"path/filepath"
@@ -18,7 +19,7 @@ type StartupCheck interface {
1819
GetType() string
1920
GetHint() string
2021
GetNeeds() []string
21-
Check(ctx context.Context, cwd string) error
22+
Check(ctx context.Context, cwd string, stdout, stderr io.Writer) error
2223
}
2324

2425
// BaseStartupCheck holds common fields for all check types
@@ -44,14 +45,18 @@ type ShellStartupCheck struct {
4445
Script string `yaml:"script"`
4546
}
4647

47-
func (s *ShellStartupCheck) Check(ctx context.Context, cwd string) error {
48+
func (s *ShellStartupCheck) Check(ctx context.Context, cwd string, stdout, stderr io.Writer) error {
4849
cmd := exec.CommandContext(ctx, s.Shell, "-c", s.Script) //nolint:gosec
4950
cmd.Dir = cwd
50-
var stderr bytes.Buffer
51-
cmd.Stderr = &stderr
51+
var stderrBuf bytes.Buffer
52+
if stderr == nil {
53+
stderr = io.Discard
54+
}
55+
cmd.Stdout = stdout
56+
cmd.Stderr = io.MultiWriter(stderr, &stderrBuf)
5257
if err := cmd.Run(); err != nil {
5358
if exitErr, ok := err.(*exec.ExitError); ok {
54-
stderrStr := strings.TrimSpace(stderr.String())
59+
stderrStr := strings.TrimSpace(stderrBuf.String())
5560
if stderrStr != "" {
5661
return fmt.Errorf("shell script exited with code %d: %s", exitErr.ExitCode(), stderrStr)
5762
}
@@ -68,12 +73,14 @@ type CommandStartupCheck struct {
6873
Command []string `yaml:"cmd"`
6974
}
7075

71-
func (s *CommandStartupCheck) Check(ctx context.Context, cwd string) error {
76+
func (s *CommandStartupCheck) Check(ctx context.Context, cwd string, stdout, stderr io.Writer) error {
7277
if len(s.Command) == 0 {
7378
return fmt.Errorf("command check has no command")
7479
}
7580
cmd := exec.CommandContext(ctx, s.Command[0], s.Command[1:]...) //nolint:gosec
7681
cmd.Dir = cwd
82+
cmd.Stdout = stdout
83+
cmd.Stderr = stderr
7784
if err := cmd.Run(); err != nil {
7885
if exitErr, ok := err.(*exec.ExitError); ok {
7986
return fmt.Errorf("command exited with code %d", exitErr.ExitCode())
@@ -90,7 +97,7 @@ type YamlPathPresentStartupCheck struct {
9097
File string `yaml:"file"`
9198
}
9299

93-
func (s *YamlPathPresentStartupCheck) Check(_ context.Context, _ string) error {
100+
func (s *YamlPathPresentStartupCheck) Check(_ context.Context, _ string, _, _ io.Writer) error {
94101
path, err := goyaml.PathString(s.Path)
95102
if err != nil {
96103
return fmt.Errorf("invalid yaml path: %w", err)

executor/executor.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"runtime/debug"
78
"strconv"
89
"strings"
910
"time"
@@ -244,7 +245,7 @@ func (e *Executor) startReadyTasks(ctx context.Context, completionCh chan<- Comm
244245
}
245246

246247
handler := ToChannelOutputHandler{OutputCh: outputCh}
247-
err := r.task.ExecuteFn(ctx, r.task.Name, handler)
248+
err := executeTask(ctx, r.task, handler)
248249
elapsed := time.Since(r.startedAt)
249250

250251
if err != nil {
@@ -276,6 +277,15 @@ func (e *Executor) startReadyTasks(ctx context.Context, completionCh chan<- Comm
276277
return nil
277278
}
278279

280+
func executeTask(ctx context.Context, task Task, handler OutputHandler) (err error) {
281+
defer func() {
282+
if recovered := recover(); recovered != nil {
283+
err = &PanicError{Task: task.Name, Value: recovered, Stack: debug.Stack()}
284+
}
285+
}()
286+
return task.ExecuteFn(ctx, task.Name, handler)
287+
}
288+
279289
// resetForRetry re-queues the named failed tasks and cascades to any skipped dependents.
280290
// Returns the number of tasks reset (caller must decrement its finished counter by this amount).
281291
func (e *Executor) resetForRetry(names []string) int {

0 commit comments

Comments
 (0)