diff --git a/.chloggen/gh-job-spans.yaml b/.chloggen/gh-job-spans.yaml new file mode 100644 index 0000000000000..2f73a4b1f124c --- /dev/null +++ b/.chloggen/gh-job-spans.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: githubreceiver + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: add GitHub workflow job spans + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [38016] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [] diff --git a/receiver/githubreceiver/README.md b/receiver/githubreceiver/README.md index e6882c2e3c8cd..2b3c301a78fe4 100644 --- a/receiver/githubreceiver/README.md +++ b/receiver/githubreceiver/README.md @@ -104,15 +104,19 @@ see the [Scraping README][ghsread]. ## Traces - Getting Started -Workflow tracing support is actively being added to the GitHub receiver. -This is accomplished through the processing of GitHub Actions webhook -events for workflows and jobs. The [`workflow_job`][wjob] and +Workflow tracing support is accomplished through the processing of GitHub +Actions webhook events for workflows and jobs. The [`workflow_job`][wjob] and [`workflow_run`][wrun] event payloads are then constructed into `trace` telemetry. Each GitHub Action workflow or job, along with its steps, are converted into trace spans, allowing the observation of workflow execution times, -success, and failure rates. +success, and failure rates. Each Trace and Span ID is deterministic. This +enables the underlying actions to emit telemetry from any command running in any +step. This can be achieved by using tools like the [run-with-telemetry +action][run] and [otel-cli][otcli]. The key is generating IDs in the same way +that this GitHub receiver does. The [trace_event_handling.go][tr] file contains +the `new*ID` functions that generate deterministic IDs. ### Receiver Configuration @@ -179,8 +183,6 @@ To configure a GitHub App, you will need to create a new GitHub App within your organization. Refer to the general [GitHub App documentation][ghapp] for how to create a GitHub App. During the subscription phase, subscribe to `workflow_run` and `workflow_job` events. -> NOTE: Only `workflow_run` events are supported in created traces at this time. - [wjob]: https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job [wrun]: https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_run [valid]: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries @@ -190,3 +192,6 @@ create a GitHub App. During the subscription phase, subscribe to `workflow_run` [doracap]: https://dora.dev/capabilities/ [dorafour]: https://dora.dev/guides/dora-metrics-four-keys/ [ghapp]: https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app +[run]: https://github.com/krzko/run-with-telemetry +[otcli]: https://github.com/equinix-labs/otel-cli +[tr]: ./trace_event_handling.go diff --git a/receiver/githubreceiver/model.go b/receiver/githubreceiver/model.go index 7acf91612f683..c54b8d4aaf143 100644 --- a/receiver/githubreceiver/model.go +++ b/receiver/githubreceiver/model.go @@ -87,7 +87,6 @@ const ( AttributeCICDPipelineTaskRunStatusSuccess = "success" AttributeCICDPipelineTaskRunStatusFailure = "failure" AttributeCICDPipelineTaskRunStatusCancellation = "cancellation" - AttributeCICDPipelineTaskRunStatusError = "error" AttributeCICDPipelineTaskRunStatusSkip = "skip" // The following attributes are not part of the semantic conventions yet. @@ -95,6 +94,12 @@ const ( AttributeCICDPipelineTaskRunSenderLogin = "cicd.pipeline.task.run.sender.login" // GitHub's Task Sender Login AttributeCICDPipelineFilePath = "cicd.pipeline.file.path" // GitHub's Path in workflow_run AttributeCICDPipelinePreviousAttemptURLFull = "cicd.pipeline.run.previous_attempt.url.full" + AttributeCICDPipelineWorkerID = "cicd.pipeline.worker.id" // GitHub's Runner ID + AttributeCICDPipelineWorkerGroupID = "cicd.pipeline.worker.group.id" // GitHub's Runner Group ID + AttributeCICDPipelineWorkerName = "cicd.pipeline.worker.name" // GitHub's Runner Name + AttributeCICDPipelineWorkerGroupName = "cicd.pipeline.worker.group.name" // GitHub's Runner Group Name + AttributeCICDPipelineWorkerNodeID = "cicd.pipeline.worker.node.id" // GitHub's Runner Node ID + AttributeCICDPipelineWorkerLabels = "cicd.pipeline.worker.labels" // GitHub's Runner Labels // The following attributes are exclusive to GitHub but not listed under // Vendor Extensions within Semantic Conventions yet. @@ -127,10 +132,10 @@ const ( AttributeVCSVendorName = "vcs.vendor.name" // GitHub ) -// getWorkflowAttrs returns a pcommon.Map of attributes for the Workflow Run +// getWorkflowRunAttrs returns a pcommon.Map of attributes for the Workflow Run // GitHub event type and an error if one occurs. The attributes are associated // with the originally provided resource. -func (gtr *githubTracesReceiver) getWorkflowAttrs(resource pcommon.Resource, e *github.WorkflowRunEvent) error { +func (gtr *githubTracesReceiver) getWorkflowRunAttrs(resource pcommon.Resource, e *github.WorkflowRunEvent) error { attrs := resource.Attributes() var err error @@ -200,6 +205,67 @@ func (gtr *githubTracesReceiver) getWorkflowAttrs(resource pcommon.Resource, e * return err } +// getWorkflowJobAttrs returns a pcommon.Map of attributes for the Workflow Job +// GitHub event type and an error if one occurs. The attributes are associated +// with the originally provided resource. +func (gtr *githubTracesReceiver) getWorkflowJobAttrs(resource pcommon.Resource, e *github.WorkflowJobEvent) error { + attrs := resource.Attributes() + var err error + + svc, err := gtr.getServiceName(e.GetRepo().CustomProperties["service_name"], e.GetRepo().GetName()) + if err != nil { + err = errors.New("failed to get service.name") + } + + attrs.PutStr(semconv.AttributeServiceName, svc) + + // VCS Attributes + attrs.PutStr(AttributeVCSRepositoryName, e.GetRepo().GetName()) + attrs.PutStr(AttributeVCSVendorName, "github") + attrs.PutStr(AttributeVCSRefHead, e.GetWorkflowJob().GetHeadBranch()) + attrs.PutStr(AttributeVCSRefHeadType, AttributeVCSRefHeadTypeBranch) + attrs.PutStr(AttributeVCSRefHeadRevision, e.GetWorkflowJob().GetHeadSHA()) + + // CICD Worker (GitHub Runner) Attributes + attrs.PutInt(AttributeCICDPipelineWorkerID, e.GetWorkflowJob().GetRunnerID()) + attrs.PutInt(AttributeCICDPipelineWorkerGroupID, e.GetWorkflowJob().GetRunnerGroupID()) + attrs.PutStr(AttributeCICDPipelineWorkerName, e.GetWorkflowJob().GetRunnerName()) + attrs.PutStr(AttributeCICDPipelineWorkerGroupName, e.GetWorkflowJob().GetRunnerGroupName()) + attrs.PutStr(AttributeCICDPipelineWorkerNodeID, e.GetWorkflowJob().GetNodeID()) + + if len(e.GetWorkflowJob().Labels) > 0 { + labels := attrs.PutEmptySlice(AttributeCICDPipelineWorkerLabels) + labels.EnsureCapacity(len(e.GetWorkflowJob().Labels)) + for _, label := range e.GetWorkflowJob().Labels { + l := strings.ToLower(label) + labels.AppendEmpty().SetStr(l) + } + } + + // CICD Attributes + attrs.PutStr(semconv.AttributeCicdPipelineName, e.GetWorkflowJob().GetName()) + attrs.PutStr(AttributeCICDPipelineTaskRunSenderLogin, e.GetSender().GetLogin()) + attrs.PutStr(semconv.AttributeCicdPipelineTaskRunURLFull, e.GetWorkflowJob().GetHTMLURL()) + attrs.PutInt(semconv.AttributeCicdPipelineTaskRunID, e.GetWorkflowJob().GetID()) + switch status := strings.ToLower(e.GetWorkflowJob().GetConclusion()); status { + case "success": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusSuccess) + case "failure": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusFailure) + case "skipped": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusSkip) + case "cancelled": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusCancellation) + // Default sets to whatever is provided by the event. GitHub provides the + // following additional values: neutral, timed_out, action_required, stale, + // and null. + default: + attrs.PutStr(AttributeCICDPipelineRunStatus, status) + } + + return err +} + // splitRefWorkflowPath splits the reference workflow path into just the file // name normalized to lowercase without the file type. func splitRefWorkflowPath(path string) (fileName string, err error) { diff --git a/receiver/githubreceiver/trace_event_handling.go b/receiver/githubreceiver/trace_event_handling.go index 792c026cad79a..fcf427765d834 100644 --- a/receiver/githubreceiver/trace_event_handling.go +++ b/receiver/githubreceiver/trace_event_handling.go @@ -13,6 +13,8 @@ import ( "github.com/google/go-github/v69/github" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/ptrace" + semconv "go.opentelemetry.io/collector/semconv/v1.27.0" + "go.uber.org/multierr" "go.uber.org/zap" ) @@ -22,9 +24,9 @@ func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent) ( resource := r.Resource() - err := gtr.getWorkflowAttrs(resource, e) + err := gtr.getWorkflowRunAttrs(resource, e) if err != nil { - return ptrace.Traces{}, fmt.Errorf("failed to get workflow attributes: %w", err) + return ptrace.Traces{}, fmt.Errorf("failed to get workflow run attributes: %w", err) } traceID, err := newTraceID(e.GetWorkflowRun().GetID(), e.GetWorkflowRun().GetRunAttempt()) @@ -40,8 +42,39 @@ func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent) ( return t, nil } -// TODO: Add and implement handleWorkflowJob, tying corresponding job spans to -// the proper root span and trace ID. +// handleWorkflowJob handles the creation of spans for a GitHub Workflow Job +// events, including the underlying steps within each job. A `job` maps to the +// semantic conventions for a `cicd.pipeline.task`. +func (gtr *githubTracesReceiver) handleWorkflowJob(e *github.WorkflowJobEvent) (ptrace.Traces, error) { + t := ptrace.NewTraces() + r := t.ResourceSpans().AppendEmpty() + + resource := r.Resource() + + err := gtr.getWorkflowJobAttrs(resource, e) + if err != nil { + return ptrace.Traces{}, fmt.Errorf("failed to get workflow run attributes: %w", err) + } + + traceID, err := newTraceID(e.GetWorkflowJob().GetRunID(), int(e.GetWorkflowJob().GetRunAttempt())) + if err != nil { + gtr.logger.Sugar().Error("failed to generate trace ID", zap.Error(err)) + } + + parentID, err := gtr.createParentSpan(r, e, traceID) + if err != nil { + gtr.logger.Sugar().Error("failed to create parent span", zap.Error(err)) + return ptrace.Traces{}, errors.New("failed to create parent span") + } + + err = gtr.createStepSpans(r, e, traceID, parentID) + if err != nil { + gtr.logger.Sugar().Error("failed to create step spans", zap.Error(err)) + return ptrace.Traces{}, errors.New("failed to create step spans") + } + + return t, nil +} // newTraceID creates a deterministic Trace ID based on the provided inputs of // runID and runAttempt. `t` is appended to the end of the input to @@ -130,3 +163,201 @@ func (gtr *githubTracesReceiver) createRootSpan( return nil } + +// createParentSpan creates a parent span based on the provided event, associated +// with the deterministic traceID. +func (gtr *githubTracesReceiver) createParentSpan( + resourceSpans ptrace.ResourceSpans, + event *github.WorkflowJobEvent, + traceID pcommon.TraceID, +) (pcommon.SpanID, error) { + scopeSpans := resourceSpans.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + + parentSpanID, err := newParentSpanID(event.GetWorkflowJob().GetRunID(), int(event.GetWorkflowJob().GetRunAttempt())) + if err != nil { + return pcommon.SpanID{}, fmt.Errorf("failed to generate parent span ID: %w", err) + } + + jobSpanID, err := newJobSpanID(event.GetWorkflowJob().GetRunID(), int(event.GetWorkflowJob().GetRunAttempt()), event.GetWorkflowJob().GetName()) + if err != nil { + return pcommon.SpanID{}, fmt.Errorf("failed to generate job span ID: %w", err) + } + + span.SetTraceID(traceID) + span.SetParentSpanID(parentSpanID) + span.SetSpanID(jobSpanID) + span.SetName(event.GetWorkflowJob().GetName()) + span.SetKind(ptrace.SpanKindServer) + + // Workflow Job event start times provided by GitHub do not always match the + // start time of the actual job. Generally they are reported a second after + // the actual step start time. Thus, we use the first step start time as the + // span start time while using the normal completedAt time for the end time. + steps := event.GetWorkflowJob().Steps + if len(steps) > 0 { + span.SetStartTimestamp(pcommon.NewTimestampFromTime(steps[0].GetStartedAt().Time)) + span.SetEndTimestamp(pcommon.NewTimestampFromTime(steps[len(steps)-1].GetCompletedAt().Time)) + } else { + span.SetStartTimestamp(pcommon.NewTimestampFromTime(event.GetWorkflowJob().GetStartedAt().Time)) + span.SetStartTimestamp(pcommon.NewTimestampFromTime(event.GetWorkflowJob().GetStartedAt().Time)) + } + + switch strings.ToLower(event.WorkflowJob.GetConclusion()) { + case "success": + span.Status().SetCode(ptrace.StatusCodeOk) + case "failure": + span.Status().SetCode(ptrace.StatusCodeError) + default: + span.Status().SetCode(ptrace.StatusCodeUnset) + } + + span.Status().SetMessage(event.GetWorkflowJob().GetConclusion()) + + return parentSpanID, nil +} + +// newJobSpanId creates a deterministic Job Span ID based on the provided runID, +// runAttempt, and the name of the job. +func newJobSpanID(runID int64, runAttempt int, jobName string) (pcommon.SpanID, error) { + input := fmt.Sprintf("%d%d%s", runID, runAttempt, jobName) + hash := sha256.Sum256([]byte(input)) + spanIDHex := hex.EncodeToString(hash[:]) + + var spanID pcommon.SpanID + _, err := hex.Decode(spanID[:], []byte(spanIDHex[16:32])) + if err != nil { + return pcommon.SpanID{}, err + } + + return spanID, nil +} + +// createStepSpans is a wrapper function to create spans for each step in the +// the workflow job by identifying duplicate names then creating a span for each +// step. +func (gtr *githubTracesReceiver) createStepSpans( + resourceSpans ptrace.ResourceSpans, + event *github.WorkflowJobEvent, + traceID pcommon.TraceID, + parentSpanID pcommon.SpanID, +) error { + steps := event.GetWorkflowJob().Steps + unique := newUniqueSteps(steps) + var errors error + for i, step := range steps { + name := unique[i] + err := gtr.createStepSpan(resourceSpans, traceID, parentSpanID, event, step, name) + if err != nil { + errors = multierr.Append(errors, err) + } + } + return errors +} + +// newUniqueSteps creates a new slice of step names from the provided GitHub +// event steps. Each step name, if duplicated, is appended with `-n` where n is +// the numbered occurrence. +func newUniqueSteps(steps []*github.TaskStep) []string { + if len(steps) == 0 { + return nil + } + + results := make([]string, len(steps)) + + count := make(map[string]int, len(steps)) + for _, step := range steps { + count[step.GetName()]++ + } + + occurrences := make(map[string]int, len(steps)) + for i, step := range steps { + name := step.GetName() + if count[name] == 1 { + results[i] = name + continue + } + + occurrences[name]++ + if occurrences[name] == 1 { + results[i] = name + } else { + results[i] = fmt.Sprintf("%s-%d", name, occurrences[name]-1) + } + } + + return results +} + +// createStepSpan creates a span with a deterministic spandID for the provided +// step. +func (gtr *githubTracesReceiver) createStepSpan( + resourceSpans ptrace.ResourceSpans, + traceID pcommon.TraceID, + parentSpanID pcommon.SpanID, + event *github.WorkflowJobEvent, + step *github.TaskStep, + name string, +) error { + scopeSpans := resourceSpans.ScopeSpans().AppendEmpty() + span := scopeSpans.Spans().AppendEmpty() + span.SetName(name) + span.SetKind(ptrace.SpanKindServer) + span.SetTraceID(traceID) + span.SetParentSpanID(parentSpanID) + + runID := event.GetWorkflowJob().GetRunID() + runAttempt := int(event.GetWorkflowJob().GetRunAttempt()) + jobName := event.GetWorkflowJob().GetName() + stepName := step.GetName() + number := int(step.GetNumber()) + spanID, err := newStepSpanID(runID, runAttempt, jobName, stepName, number) + if err != nil { + return fmt.Errorf("failed to generate step span ID: %w", err) + } + + span.SetSpanID(spanID) + + attrs := span.Attributes() + attrs.PutStr(semconv.AttributeCicdPipelineTaskName, name) + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, step.GetStatus()) + span.SetStartTimestamp(pcommon.NewTimestampFromTime(step.GetStartedAt().Time)) + span.SetEndTimestamp(pcommon.NewTimestampFromTime(step.GetCompletedAt().Time)) + + switch strings.ToLower(step.GetConclusion()) { + case "success": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusSuccess) + span.Status().SetCode(ptrace.StatusCodeOk) + case "failure": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusFailure) + span.Status().SetCode(ptrace.StatusCodeError) + case "skipped": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusFailure) + span.Status().SetCode(ptrace.StatusCodeUnset) + case "cancelled": + attrs.PutStr(AttributeCICDPipelineTaskRunStatus, AttributeCICDPipelineTaskRunStatusCancellation) + span.Status().SetCode(ptrace.StatusCodeUnset) + default: + span.Status().SetCode(ptrace.StatusCodeUnset) + } + + span.Status().SetMessage(event.GetWorkflowJob().GetConclusion()) + + return nil +} + +// newStepSpanID creates a deterministic Step Span ID based on the provided +// inputs. +func newStepSpanID(runID int64, runAttempt int, jobName string, stepName string, number int) (pcommon.SpanID, error) { + input := fmt.Sprintf("%d%d%s%s%d", runID, runAttempt, jobName, stepName, number) + hash := sha256.Sum256([]byte(input)) + spanIDHex := hex.EncodeToString(hash[:]) + + var spanID pcommon.SpanID + _, err := hex.Decode(spanID[:], []byte(spanIDHex[16:32])) + if err != nil { + return pcommon.SpanID{}, err + } + + return spanID, nil +} diff --git a/receiver/githubreceiver/trace_event_handling_test.go b/receiver/githubreceiver/trace_event_handling_test.go index 4c34710520d9f..f7e3a30c59d98 100644 --- a/receiver/githubreceiver/trace_event_handling_test.go +++ b/receiver/githubreceiver/trace_event_handling_test.go @@ -217,3 +217,500 @@ func TestNewParentSpanID_Consistency(t *testing.T) { require.Equal(t, spanID1, spanID2, "span ID should be consistent across multiple calls") } } + +func TestNewUniqueSteps(t *testing.T) { + tests := []struct { + name string + steps []*github.TaskStep + expected []string + }{ + { + name: "nil steps", + steps: nil, + expected: nil, + }, + { + name: "empty steps", + steps: []*github.TaskStep{}, + expected: nil, + }, + { + name: "no duplicate steps", + steps: []*github.TaskStep{ + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Test")}, + {Name: github.Ptr("Deploy")}, + }, + expected: []string{"Build", "Test", "Deploy"}, + }, + { + name: "with duplicate steps", + steps: []*github.TaskStep{ + {Name: github.Ptr("Setup")}, + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Test")}, + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Test")}, + {Name: github.Ptr("Deploy")}, + }, + expected: []string{"Setup", "Build", "Test", "Build-1", "Test-1", "Deploy"}, + }, + { + name: "multiple duplicates of same step", + steps: []*github.TaskStep{ + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Build")}, + {Name: github.Ptr("Build")}, + }, + expected: []string{"Build", "Build-1", "Build-2", "Build-3"}, + }, + { + name: "with empty step names", + steps: []*github.TaskStep{ + {Name: github.Ptr("")}, + {Name: github.Ptr("")}, + {Name: github.Ptr("Build")}, + }, + expected: []string{"", "-1", "Build"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := newUniqueSteps(tt.steps) + + // Check length matches + if len(result) != len(tt.expected) { + t.Errorf("length mismatch: got %d, want %d", len(result), len(tt.expected)) + return + } + + // Check contents match + for i := 0; i < len(result); i++ { + if result[i] != tt.expected[i] { + t.Errorf("at index %d: got %q, want %q", i, result[i], tt.expected[i]) + } + } + }) + } +} + +func TestCreateStepSpans(t *testing.T) { + // Helper function to create a workflow job event with steps + createTestWorkflowJobEvent := func(steps []*github.TaskStep) *github.WorkflowJobEvent { + return &github.WorkflowJobEvent{ + WorkflowJob: &github.WorkflowJob{ + ID: github.Ptr(int64(123)), + RunID: github.Ptr(int64(456)), + RunAttempt: github.Ptr(int64(1)), + Name: github.Ptr("Test Job"), + Steps: steps, + }, + } + } + + // Helper function to create a timestamp + now := time.Now() + createTimestamp := func(offsetMinutes int) *github.Timestamp { + return &github.Timestamp{Time: now.Add(time.Duration(offsetMinutes) * time.Minute)} + } + + tests := []struct { + name string + event *github.WorkflowJobEvent + wantErr bool + validateFn func(t *testing.T, spans ptrace.SpanSlice) + }{ + { + name: "single step", + event: createTestWorkflowJobEvent([]*github.TaskStep{ + { + Name: github.Ptr("Build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + Number: github.Ptr(int64(1)), + StartedAt: createTimestamp(0), + CompletedAt: createTimestamp(5), + }, + }), + wantErr: false, + validateFn: func(t *testing.T, spans ptrace.SpanSlice) { + require.Equal(t, 1, spans.Len()) + span := spans.At(0) + require.Equal(t, "Build", span.Name()) + require.Equal(t, ptrace.StatusCodeOk, span.Status().Code()) + }, + }, + { + name: "multiple steps with different states", + event: createTestWorkflowJobEvent([]*github.TaskStep{ + { + Name: github.Ptr("Setup"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + Number: github.Ptr(int64(1)), + StartedAt: createTimestamp(0), + CompletedAt: createTimestamp(2), + }, + { + Name: github.Ptr("Build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + Number: github.Ptr(int64(2)), + StartedAt: createTimestamp(2), + CompletedAt: createTimestamp(5), + }, + { + Name: github.Ptr("Test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("skipped"), + Number: github.Ptr(int64(3)), + StartedAt: createTimestamp(5), + CompletedAt: createTimestamp(6), + }, + }), + wantErr: false, + validateFn: func(t *testing.T, spans ptrace.SpanSlice) { + require.Equal(t, 3, spans.Len()) + + // Setup step + setup := spans.At(0) + require.Equal(t, "Setup", setup.Name()) + require.Equal(t, ptrace.StatusCodeOk, setup.Status().Code()) + + // Build step + build := spans.At(1) + require.Equal(t, "Build", build.Name()) + require.Equal(t, ptrace.StatusCodeError, build.Status().Code()) + + // Test step + test := spans.At(2) + require.Equal(t, "Test", test.Name()) + require.Equal(t, ptrace.StatusCodeUnset, test.Status().Code()) + }, + }, + { + name: "duplicate step names", + event: createTestWorkflowJobEvent([]*github.TaskStep{ + { + Name: github.Ptr("Build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + Number: github.Ptr(int64(1)), + StartedAt: createTimestamp(0), + CompletedAt: createTimestamp(2), + }, + { + Name: github.Ptr("Build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + Number: github.Ptr(int64(2)), + StartedAt: createTimestamp(2), + CompletedAt: createTimestamp(4), + }, + }), + wantErr: false, + validateFn: func(t *testing.T, spans ptrace.SpanSlice) { + require.Equal(t, 2, spans.Len()) + require.Equal(t, "Build", spans.At(0).Name()) + require.Equal(t, "Build-1", spans.At(1).Name()) + }, + }, + { + name: "no steps", + event: createTestWorkflowJobEvent([]*github.TaskStep{}), + wantErr: false, + validateFn: func(t *testing.T, spans ptrace.SpanSlice) { + require.Equal(t, 0, spans.Len()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a new receiver with a test logger + logger := zap.NewNop() + receiver := &githubTracesReceiver{ + logger: logger, + cfg: createDefaultConfig().(*Config), + settings: receivertest.NewNopSettings(metadata.Type), + } + + // Create traces and resource spans + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + + // Generate a trace ID and parent span ID for testing + traceID, err := newTraceID(tt.event.GetWorkflowJob().GetRunID(), int(tt.event.GetWorkflowJob().GetRunAttempt())) + require.NoError(t, err) + parentSpanID, err := newParentSpanID(tt.event.GetWorkflowJob().GetID(), int(tt.event.GetWorkflowJob().GetRunAttempt())) + require.NoError(t, err) + + // Call createStepSpans + err = receiver.createStepSpans(resourceSpans, tt.event, traceID, parentSpanID) + + if tt.wantErr { + require.Error(t, err) + return + } + // Get all spans from all scope spans + var allSpans []ptrace.Span + for i := 0; i < resourceSpans.ScopeSpans().Len(); i++ { + scopeSpans := resourceSpans.ScopeSpans().At(i) + spans := scopeSpans.Spans() + for j := 0; j < spans.Len(); j++ { + allSpans = append(allSpans, spans.At(j)) + } + } + + // Convert to SpanSlice for validation + spanSlice := ptrace.NewSpanSlice() + for _, span := range allSpans { + spanCopy := spanSlice.AppendEmpty() + span.CopyTo(spanCopy) + } + + // Run validation + tt.validateFn(t, spanSlice) + }) + } +} + +func TestNewStepSpanID(t *testing.T) { + tests := []struct { + name string + runID int64 + runAttempt int + jobName string + stepName string + number int + wantError bool + }{ + { + name: "basic step span ID", + runID: 12345, + runAttempt: 1, + jobName: "test-job", + stepName: "build", + number: 1, + wantError: false, + }, + { + name: "different run ID", + runID: 54321, + runAttempt: 1, + jobName: "test-job", + stepName: "build", + number: 1, + wantError: false, + }, + { + name: "different attempt", + runID: 12345, + runAttempt: 2, + jobName: "test-job", + stepName: "build", + number: 1, + wantError: false, + }, + { + name: "different job name", + runID: 12345, + runAttempt: 1, + jobName: "other-job", + stepName: "build", + number: 1, + wantError: false, + }, + { + name: "different step name", + runID: 12345, + runAttempt: 1, + jobName: "test-job", + stepName: "test", + number: 1, + wantError: false, + }, + { + name: "different number", + runID: 12345, + runAttempt: 1, + jobName: "test-job", + stepName: "build", + number: 2, + wantError: false, + }, + { + name: "zero values", + runID: 0, + runAttempt: 0, + jobName: "", + stepName: "", + number: 0, + wantError: false, + }, + { + name: "with special characters in names", + runID: 12345, + runAttempt: 1, + jobName: "test-job!@#$%^&*()", + stepName: "build step with spaces", + number: 1, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // First call to get span ID + spanID1, err1 := newStepSpanID(tt.runID, tt.runAttempt, tt.jobName, tt.stepName, tt.number) + + if tt.wantError { + require.Error(t, err1) + return + } + require.NoError(t, err1) + + // Verify span ID is not empty + require.NotEqual(t, pcommon.SpanID{}, spanID1, "span ID should not be empty") + + // Verify consistent results for same input + spanID2, err2 := newStepSpanID(tt.runID, tt.runAttempt, tt.jobName, tt.stepName, tt.number) + require.NoError(t, err2) + require.Equal(t, spanID1, spanID2, "same inputs should generate same span ID") + + // Verify different inputs generate different span IDs + differentSpanID, err3 := newStepSpanID(tt.runID+1, tt.runAttempt, tt.jobName, tt.stepName, tt.number) + require.NoError(t, err3) + require.NotEqual(t, spanID1, differentSpanID, "different inputs should generate different span IDs") + }) + } +} + +func TestNewStepSpanID_Consistency(t *testing.T) { + // Test that generates the same span ID for same inputs across multiple calls + runID := int64(12345) + runAttempt := 1 + jobName := "test-job" + stepName := "build" + number := 1 + + spanID1, err1 := newStepSpanID(runID, runAttempt, jobName, stepName, number) + require.NoError(t, err1) + + for i := 0; i < 5; i++ { + spanID2, err2 := newStepSpanID(runID, runAttempt, jobName, stepName, number) + require.NoError(t, err2) + require.Equal(t, spanID1, spanID2, "span ID should be consistent across multiple calls") + } +} + +func TestNewJobSpanID(t *testing.T) { + tests := []struct { + name string + runID int64 + runAttempt int + jobName string + wantError bool + }{ + { + name: "basic job span ID", + runID: 12345, + runAttempt: 1, + jobName: "test-job", + wantError: false, + }, + { + name: "different run ID", + runID: 54321, + runAttempt: 1, + jobName: "test-job", + wantError: false, + }, + { + name: "different attempt", + runID: 12345, + runAttempt: 2, + jobName: "test-job", + wantError: false, + }, + { + name: "different job name", + runID: 12345, + runAttempt: 1, + jobName: "other-job", + wantError: false, + }, + { + name: "zero values", + runID: 0, + runAttempt: 0, + jobName: "", + wantError: false, + }, + { + name: "with special characters in job name", + runID: 12345, + runAttempt: 1, + jobName: "test-job!@#$%^&*()", + wantError: false, + }, + { + name: "with spaces in job name", + runID: 12345, + runAttempt: 1, + jobName: "test job with spaces", + wantError: false, + }, + { + name: "with unicode in job name", + runID: 12345, + runAttempt: 1, + jobName: "测试工作", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // First call to get span ID + spanID1, err1 := newJobSpanID(tt.runID, tt.runAttempt, tt.jobName) + + if tt.wantError { + require.Error(t, err1) + return + } + require.NoError(t, err1) + + // Verify span ID is not empty + require.NotEqual(t, pcommon.SpanID{}, spanID1, "span ID should not be empty") + + // Verify consistent results for same input + spanID2, err2 := newJobSpanID(tt.runID, tt.runAttempt, tt.jobName) + require.NoError(t, err2) + require.Equal(t, spanID1, spanID2, "same inputs should generate same span ID") + + // Verify different inputs generate different span IDs + differentSpanID, err3 := newJobSpanID(tt.runID+1, tt.runAttempt, tt.jobName) + require.NoError(t, err3) + require.NotEqual(t, spanID1, differentSpanID, "different inputs should generate different span IDs") + }) + } +} + +func TestNewJobSpanID_Consistency(t *testing.T) { + // Test that generates the same span ID for same inputs across multiple calls + runID := int64(12345) + runAttempt := 1 + jobName := "test-job" + + spanID1, err1 := newJobSpanID(runID, runAttempt, jobName) + require.NoError(t, err1) + + for i := 0; i < 5; i++ { + spanID2, err2 := newJobSpanID(runID, runAttempt, jobName) + require.NoError(t, err2) + require.Equal(t, spanID1, spanID2, "span ID should be consistent across multiple calls") + } +} diff --git a/receiver/githubreceiver/trace_receiver.go b/receiver/githubreceiver/trace_receiver.go index 82335c038476f..89d043e67c837 100644 --- a/receiver/githubreceiver/trace_receiver.go +++ b/receiver/githubreceiver/trace_receiver.go @@ -164,10 +164,7 @@ func (gtr *githubTracesReceiver) handleReq(w http.ResponseWriter, req *http.Requ w.WriteHeader(http.StatusNoContent) return } - return - // TODO: Enable when handleWorkflowJob is implemented - // See: https://github.com/open-telemetry/semantic-conventions/issues/1645 - // td, err = gtr.handleWorkflowJob(ctx, e) + td, err = gtr.handleWorkflowJob(e) case *github.PingEvent: w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusOK)