mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-08 08:10:30 +00:00
feat: OpenTelemetry tracing (#1206)
Adds OpenTelemetry tracing, configured through the standard `OTEL_*` environment variables and off until an OTLP endpoint is set.  Each job becomes a trace with a span per step, so operators can see where CI time goes in their existing observability backend. Jobs receive only `TRACEPARENT`, so instrumented tools inside them join the trace without seeing the collector's credentials. Metrics stay on the Prometheus endpoint, which a collector can scrape. See [docs/telemetry.md](https://gitea.com/wmTJc9IK0Q/runner/src/branch/otel/docs/telemetry.md) for setup and security notes. Also keeps `GITHUB_SHA` and `GITHUB_REF` fixed for a whole run when HEAD moves during `exec` runs and the act tests. [image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477) [image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477) --------- Co-authored-by: silverwind <[email protected]> Co-authored-by: silverwind <[email protected]> Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1206 Reviewed-by: bircni <[email protected]> Reviewed-by: silverwind <[email protected]> Co-authored-by: wmTJc9IK0Q <[email protected]>
This commit is contained in:
1 parent
7ad12038d1
commit
1952e5cdb6
15 files changed
+603
-28
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
@@ -25,6 +26,26 @@ var (
|
||||
findGithubRepo = git.FindGithubRepo
|
||||
)
|
||||
|
||||
type pinnedKey struct{}
|
||||
|
||||
// WithPinnedCheckout keeps a run's sha and ref fixed when HEAD moves.
|
||||
func WithPinnedCheckout(ctx context.Context) context.Context {
|
||||
if ctx.Value(pinnedKey{}) != nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, pinnedKey{}, &sync.Map{})
|
||||
}
|
||||
|
||||
func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) {
|
||||
cache, ok := ctx.Value(pinnedKey{}).(*sync.Map)
|
||||
if !ok {
|
||||
return lookup()
|
||||
}
|
||||
cached, _ := cache.LoadOrStore(key, sync.OnceValues(lookup))
|
||||
lookupOnce, _ := cached.(func() (string, error))
|
||||
return lookupOnce()
|
||||
}
|
||||
|
||||
// SetRef resolves the ref of the context from its event payload, falling back
|
||||
// to the ref checked out in repoPath.
|
||||
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
@@ -51,7 +72,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ref, err := findGitRef(ctx, repoPath)
|
||||
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git ref: %v", err)
|
||||
} else {
|
||||
@@ -95,7 +116,10 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
}
|
||||
|
||||
if ghc.Sha == "" {
|
||||
_, sha, err := findGitRevision(ctx, repoPath)
|
||||
sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) {
|
||||
_, revision, err := findGitRevision(ctx, repoPath)
|
||||
return revision, err
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git revision: %v", err)
|
||||
} else {
|
||||
|
||||
@@ -214,4 +214,17 @@ func TestSetSha(t *testing.T) {
|
||||
assert.Equal(t, table.sha, ghc.Sha)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
|
||||
ctx := WithPinnedCheckout(context.Background())
|
||||
for _, head := range []string{"first", "moved"} {
|
||||
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
|
||||
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
|
||||
ghc := &model.GithubContext{Event: map[string]any{}}
|
||||
SetSha(ctx, ghc, "/some/dir")
|
||||
SetRef(ctx, ghc, "/some/dir")
|
||||
assert.Equal(t, "first", ghc.Sha)
|
||||
assert.Equal(t, "refs/heads/first", ghc.Ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -249,7 +250,8 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
||||
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
||||
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(context.Background()), jobSpan))
|
||||
cancel()
|
||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||
|
||||
@@ -258,6 +260,7 @@ func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||
|
||||
// Detached from cancellation, so the post steps actually run.
|
||||
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||
assert.Equal(t, jobSpan, trace.SpanContextFromContext(postCtx), "post steps must stay in the job's trace")
|
||||
|
||||
// A failing post step records its error instead of panicking.
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@@ -281,8 +281,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||
//
|
||||
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
||||
// the post steps can run.
|
||||
// - context.Canceled (server cancel): detach with WithoutCancel under a fresh job-error
|
||||
// container, so a failing post step cannot turn the cancellation into a failure.
|
||||
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||
// keeps the original values — including the job-error container — so the timeout failure and
|
||||
// any post-step error are preserved and the job is still reported as failed.
|
||||
@@ -290,10 +290,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
switch ctx.Err() {
|
||||
case context.Canceled:
|
||||
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
||||
// container installed at the job root. Re-attach a fresh one so a failing post step
|
||||
// records its error via SetJobError instead of panicking on a nil container.
|
||||
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
||||
return context.WithTimeout(common.WithJobErrorContainer(context.WithoutCancel(ctx)), 5*time.Minute)
|
||||
case context.DeadlineExceeded:
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
default:
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/ghcontext"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
@@ -321,7 +322,8 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
})
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
||||
executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
||||
return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) }
|
||||
}
|
||||
|
||||
func handleFailure(plan *model.Plan) common.Executor {
|
||||
|
||||
+9
-5
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -65,24 +66,27 @@ func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunCo
|
||||
}
|
||||
|
||||
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return func(ctx context.Context) (err error) {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
ifExpression := step.getIfExpression(ctx, stage)
|
||||
rc.CurrentStep = stepModel.ID
|
||||
|
||||
stepResult := &model.StepResult{
|
||||
Outcome: model.StepStatusSuccess,
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
ctx, endStep := telemetry.StartStep(ctx, stepModel, stage.String())
|
||||
defer func() { endStep(err, stepResult) }()
|
||||
|
||||
ifExpression := step.getIfExpression(ctx, stage)
|
||||
rc.CurrentStep = stepModel.ID
|
||||
|
||||
if stage == stepStageMain {
|
||||
rc.StepResults[rc.CurrentStep] = stepResult
|
||||
}
|
||||
|
||||
err := setupEnv(ctx, step)
|
||||
err = setupEnv(ctx, step)
|
||||
var runStep bool
|
||||
if err == nil {
|
||||
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||
|
||||
@@ -13,13 +13,19 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -547,6 +553,29 @@ func TestRunStepExecutorParity(t *testing.T) {
|
||||
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
|
||||
})
|
||||
|
||||
t.Run("stages and composite steps are traced under Gitea's step names", func(t *testing.T) {
|
||||
spans := tracetest.NewSpanRecorder()
|
||||
telemetry.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans)))
|
||||
t.Cleanup(func() { telemetry.SetTracerProvider(tracenoop.NewTracerProvider()) })
|
||||
ctx, _ := telemetry.StartJob(t.Context(), &runnerv1.Task{Id: 7})
|
||||
outer := newStep(t, &model.Step{ID: "outer", Uses: "actions/cache@v4", Number: 3})
|
||||
child := newStep(t, &model.Step{ID: "child", Name: "Restore", Number: 1})
|
||||
skipped := newStep(t, &model.Step{ID: "skipped", Name: "Deploy", Number: 4, If: yaml.Node{Value: "false"}})
|
||||
broken := newStep(t, &model.Step{ID: "broken", Name: "Broken", Number: 5, If: yaml.Node{Value: badExpression}})
|
||||
|
||||
require.NoError(t, runStepExecutor(outer, stepStagePost, runStepExecutor(child, stepStageMain, noopExecutor))(ctx))
|
||||
require.NoError(t, runStepExecutor(skipped, stepStageMain, noopExecutor)(ctx))
|
||||
require.Error(t, runStepExecutor(broken, stepStageMain, noopExecutor)(ctx))
|
||||
|
||||
ended := spans.Ended()
|
||||
require.Len(t, ended, 4)
|
||||
assert.Equal(t, []string{"Restore", "Post Run actions/cache@v4", "Deploy", "Broken"}, []string{ended[0].Name(), ended[1].Name(), ended[2].Name(), ended[3].Name()})
|
||||
assert.Equal(t, ended[1].SpanContext().SpanID(), ended[0].Parent().SpanID())
|
||||
assert.Contains(t, ended[0].Attributes(), semconv.CICDPipelineTaskRunID("7.3.post.1"))
|
||||
assert.Contains(t, ended[2].Attributes(), semconv.CICDPipelineTaskRunResultSkip)
|
||||
assert.Contains(t, ended[3].Attributes(), semconv.CICDPipelineTaskRunResultFailure)
|
||||
})
|
||||
|
||||
t.Run("timeout must be positive", func(t *testing.T) {
|
||||
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
|
||||
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
|
||||
|
||||
Reference in new issue
Block a user