diff --git a/.chloggen/spanevents.yaml b/.chloggen/spanevents.yaml new file mode 100644 index 0000000000000..87beaf3d0b0d6 --- /dev/null +++ b/.chloggen/spanevents.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. receiver/filelog) +component: receiver/github + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `include_span_events` for GitHub Workflow Runs and Jobs for enhanced troubleshooting + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [43180] + +# (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: [user] diff --git a/receiver/githubreceiver/README.md b/receiver/githubreceiver/README.md index 896a5e7c5a676..67ea152a81e87 100644 --- a/receiver/githubreceiver/README.md +++ b/receiver/githubreceiver/README.md @@ -159,6 +159,7 @@ The WebHook configuration exposes the following settings: * `service_name`: (optional) - The service name for the traces. See the [Configuring Service Name](#configuring-service-name) section for more information. +* `include_span_events`: (default = `false`) - When set to `true`, attaches the raw webhook event JSON as a span event. The workflow run event is attached to the workflow run span, and the workflow job event is attached to the job span. The WebHook configuration block also accepts all the [confighttp][cfghttp] settings. @@ -206,6 +207,35 @@ The precedence for setting the `service.name` resource attribute is as follows: 3. `service_name` derived from the repository name. 4. `service.name` set to `unknown_service` per the semantic conventions as a fall back. +### Span Events + +When `include_span_events` is enabled, the receiver attaches the raw GitHub webhook event JSON as a span event to the corresponding span: + +- **Workflow Run events**: Attached as a span event named `github.workflow_run.event` to the root workflow run span +- **Workflow Job events**: Attached as a span event named `github.workflow_job.event` to the job span + +The raw event is stored in the `event.payload` attribute as a JSON string. This allows for detailed inspection of the complete webhook payload, including fields that may not be mapped to span attributes. + +**Note**: The raw event payload can be large (typically 5-50KB). Consider the impact on storage and performance before enabling this feature in production environments. + +An example configuration with span events enabled: + +```yaml +receivers: + github: + webhook: + endpoint: localhost:19418 + path: /events + health_path: /health + secret: ${env:SECRET_STRING_VAR} + required_headers: + WAF-Header: "value" + include_span_events: true + scrapers: # The validation expects at least a dummy scraper config + scraper: + github_org: open-telemetry +``` + ### Configuring A GitHub App To configure a GitHub App, you will need to create a new GitHub App within your diff --git a/receiver/githubreceiver/config.go b/receiver/githubreceiver/config.go index 113b95599a904..d87747763b1e3 100755 --- a/receiver/githubreceiver/config.go +++ b/receiver/githubreceiver/config.go @@ -46,6 +46,7 @@ type WebHook struct { GitHubHeaders GitHubHeaders `mapstructure:",squash"` // GitLab headers set by default Secret string `mapstructure:"secret"` // secret for webhook ServiceName string `mapstructure:"service_name"` + IncludeSpanEvents bool `mapstructure:"include_span_events"` // attach raw webhook event JSON as span events } type GitHubHeaders struct { diff --git a/receiver/githubreceiver/config_test.go b/receiver/githubreceiver/config_test.go index fea2cc462dbb5..ee77a5c4802e8 100644 --- a/receiver/githubreceiver/config_test.go +++ b/receiver/githubreceiver/config_test.go @@ -164,3 +164,32 @@ func TestConfig_Unmarshal(t *testing.T) { }) } } + +func TestIncludeSpanEventsConfig(t *testing.T) { + tests := []struct { + name string + config *Config + expected bool + }{ + { + name: "default config has span events disabled", + config: createDefaultConfig().(*Config), + expected: false, + }, + { + name: "span events can be enabled", + config: &Config{ + WebHook: WebHook{ + IncludeSpanEvents: true, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.config.WebHook.IncludeSpanEvents) + }) + } +} diff --git a/receiver/githubreceiver/factory.go b/receiver/githubreceiver/factory.go index 51112a497f100..63ad7602975e5 100644 --- a/receiver/githubreceiver/factory.go +++ b/receiver/githubreceiver/factory.go @@ -24,11 +24,12 @@ import ( // This file implements a factory for the github receiver const ( - defaultReadTimeout = 500 * time.Millisecond - defaultWriteTimeout = 500 * time.Millisecond - defaultPath = "/events" - defaultHealthPath = "/health" - defaultEndpoint = "localhost:8080" + defaultReadTimeout = 500 * time.Millisecond + defaultWriteTimeout = 500 * time.Millisecond + defaultPath = "/events" + defaultHealthPath = "/health" + defaultEndpoint = "localhost:8080" + defaultIncludeSpanEvents = false ) var ( @@ -79,8 +80,9 @@ func createDefaultConfig() component.Config { defaultGitHubSignature256Header: "", }, }, - Path: defaultPath, - HealthPath: defaultHealthPath, + Path: defaultPath, + HealthPath: defaultHealthPath, + IncludeSpanEvents: defaultIncludeSpanEvents, }, } } diff --git a/receiver/githubreceiver/trace_event_handling.go b/receiver/githubreceiver/trace_event_handling.go index 3daef0c42e351..ed998d4a11ce9 100644 --- a/receiver/githubreceiver/trace_event_handling.go +++ b/receiver/githubreceiver/trace_event_handling.go @@ -19,7 +19,7 @@ import ( "go.uber.org/zap" ) -func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent) (ptrace.Traces, error) { +func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent, rawPayload []byte) (ptrace.Traces, error) { t := ptrace.NewTraces() r := t.ResourceSpans().AppendEmpty() @@ -35,7 +35,7 @@ func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent) ( gtr.logger.Sugar().Error("failed to generate trace ID", zap.Error(err)) } - err = gtr.createRootSpan(r, e, traceID) + err = gtr.createRootSpan(r, e, traceID, rawPayload) if err != nil { gtr.logger.Sugar().Error("failed to create root span", zap.Error(err)) return ptrace.Traces{}, errors.New("failed to create root span") @@ -46,7 +46,7 @@ func (gtr *githubTracesReceiver) handleWorkflowRun(e *github.WorkflowRunEvent) ( // 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) { +func (gtr *githubTracesReceiver) handleWorkflowJob(e *github.WorkflowJobEvent, rawPayload []byte) (ptrace.Traces, error) { t := ptrace.NewTraces() r := t.ResourceSpans().AppendEmpty() @@ -62,7 +62,7 @@ func (gtr *githubTracesReceiver) handleWorkflowJob(e *github.WorkflowJobEvent) ( gtr.logger.Sugar().Error("failed to generate trace ID", zap.Error(err)) } - parentID, err := gtr.createParentSpan(r, e, traceID) + parentID, err := gtr.createParentSpan(r, e, traceID, rawPayload) 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") @@ -127,6 +127,7 @@ func (gtr *githubTracesReceiver) createRootSpan( resourceSpans ptrace.ResourceSpans, event *github.WorkflowRunEvent, traceID pcommon.TraceID, + rawPayload []byte, ) error { scopeSpans := resourceSpans.ScopeSpans().AppendEmpty() span := scopeSpans.Spans().AppendEmpty() @@ -154,6 +155,14 @@ func (gtr *githubTracesReceiver) createRootSpan( span.Status().SetMessage(event.GetWorkflowRun().GetConclusion()) + // Attach raw event as span event if configured + if gtr.cfg.WebHook.IncludeSpanEvents { + spanEvent := span.Events().AppendEmpty() + spanEvent.SetName("github.workflow_run.event") + spanEvent.SetTimestamp(pcommon.NewTimestampFromTime(event.GetWorkflowRun().GetRunStartedAt().Time)) + spanEvent.Attributes().PutStr("event.payload", string(rawPayload)) + } + // Attempt to link to previous trace ID if applicable if event.GetWorkflowRun().GetPreviousAttemptURL() != "" && event.GetWorkflowRun().GetRunAttempt() > 1 { gtr.logger.Debug("Linking to previous trace ID for WorkflowRunEvent") @@ -173,10 +182,11 @@ func (gtr *githubTracesReceiver) createRootSpan( // createParentSpan creates a parent span based on the provided event, associated // with the deterministic traceID. -func (*githubTracesReceiver) createParentSpan( +func (gtr *githubTracesReceiver) createParentSpan( resourceSpans ptrace.ResourceSpans, event *github.WorkflowJobEvent, traceID pcommon.TraceID, + rawPayload []byte, ) (pcommon.SpanID, error) { scopeSpans := resourceSpans.ScopeSpans().AppendEmpty() span := scopeSpans.Spans().AppendEmpty() @@ -211,6 +221,14 @@ func (*githubTracesReceiver) createParentSpan( span.Status().SetMessage(event.GetWorkflowJob().GetConclusion()) + // Attach raw event as span event if configured + if gtr.cfg.WebHook.IncludeSpanEvents { + spanEvent := span.Events().AppendEmpty() + spanEvent.SetName("github.workflow_job.event") + spanEvent.SetTimestamp(pcommon.NewTimestampFromTime(event.GetWorkflowJob().GetCreatedAt().Time)) + spanEvent.Attributes().PutStr("event.payload", string(rawPayload)) + } + return jobSpanID, nil } diff --git a/receiver/githubreceiver/trace_event_handling_test.go b/receiver/githubreceiver/trace_event_handling_test.go index 4477eafcc9df0..81a0730d36d49 100644 --- a/receiver/githubreceiver/trace_event_handling_test.go +++ b/receiver/githubreceiver/trace_event_handling_test.go @@ -39,7 +39,7 @@ func TestHandleWorkflowRunWithGoldenFile(t *testing.T) { err = json.Unmarshal(data, &event) require.NoError(t, err, "Failed to unmarshal workflow run event") - traces, err := receiver.handleWorkflowRun(&event) + traces, err := receiver.handleWorkflowRun(&event, data) require.NoError(t, err, "Failed to handle workflow run event") expectedFile := filepath.Join("testdata", "workflow-run-expected.yaml") @@ -69,7 +69,7 @@ func TestHandleWorkflowJobWithGoldenFile(t *testing.T) { err = json.Unmarshal(data, &event) require.NoError(t, err, "Failed to unmarshal workflow job event") - traces, err := receiver.handleWorkflowJob(&event) + traces, err := receiver.handleWorkflowJob(&event, data) require.NoError(t, err, "Failed to handle workflow job event") expectedFile := filepath.Join("testdata", "workflow-job-expected.yaml") @@ -99,7 +99,7 @@ func TestHandleWorkflowJobWithGoldenFileSkipped(t *testing.T) { err = json.Unmarshal(data, &event) require.NoError(t, err, "Failed to unmarshal workflow job event") - traces, err := receiver.handleWorkflowJob(&event) + traces, err := receiver.handleWorkflowJob(&event, data) require.NoError(t, err, "Failed to handle workflow job event") expectedFile := filepath.Join("testdata", "workflow-job-skipped-expected.yaml") @@ -703,3 +703,166 @@ func TestNewJobSpanID_Consistency(t *testing.T) { require.Equal(t, spanID1, spanID2, "span ID should be consistent across multiple calls") } } + +func TestHandleWorkflowRunWithSpanEvents(t *testing.T) { + config := createDefaultConfig().(*Config) + config.WebHook.Endpoint = "localhost:0" + config.WebHook.IncludeSpanEvents = true // Enable span events + consumer := consumertest.NewNop() + + receiver, err := newTracesReceiver(receivertest.NewNopSettings(metadata.Type), config, consumer) + require.NoError(t, err, "failed to create receiver") + + testFilePath := filepath.Join("testdata", "workflow-run-completed.json") + data, err := os.ReadFile(testFilePath) + require.NoError(t, err, "Failed to read test data file") + + var event github.WorkflowRunEvent + err = json.Unmarshal(data, &event) + require.NoError(t, err, "Failed to unmarshal workflow run event") + + traces, err := receiver.handleWorkflowRun(&event, data) + require.NoError(t, err, "Failed to handle workflow run event") + + // Verify span event is present + resourceSpans := traces.ResourceSpans() + require.Equal(t, 1, resourceSpans.Len()) + + scopeSpans := resourceSpans.At(0).ScopeSpans() + require.Positive(t, scopeSpans.Len(), 0) + + spans := scopeSpans.At(0).Spans() + require.Positive(t, spans.Len(), 0) + + rootSpan := spans.At(0) + events := rootSpan.Events() + require.Equal(t, 1, events.Len(), "Expected one span event") + + spanEvent := events.At(0) + require.Equal(t, "github.workflow_run.event", spanEvent.Name()) + + payload, exists := spanEvent.Attributes().Get("event.payload") + require.True(t, exists, "event.payload attribute should exist") + require.NotEmpty(t, payload.Str(), "event.payload should not be empty") + + // Verify the payload is valid JSON + var unmarshaled map[string]any + err = json.Unmarshal([]byte(payload.Str()), &unmarshaled) + require.NoError(t, err, "event.payload should be valid JSON") +} + +func TestHandleWorkflowJobWithSpanEvents(t *testing.T) { + config := createDefaultConfig().(*Config) + config.WebHook.Endpoint = "localhost:0" + config.WebHook.IncludeSpanEvents = true // Enable span events + consumer := consumertest.NewNop() + + receiver, err := newTracesReceiver(receivertest.NewNopSettings(metadata.Type), config, consumer) + require.NoError(t, err, "failed to create receiver") + + testFilePath := filepath.Join("testdata", "workflow-job-completed.json") + data, err := os.ReadFile(testFilePath) + require.NoError(t, err, "Failed to read test data file") + + var event github.WorkflowJobEvent + err = json.Unmarshal(data, &event) + require.NoError(t, err, "Failed to unmarshal workflow job event") + + traces, err := receiver.handleWorkflowJob(&event, data) + require.NoError(t, err, "Failed to handle workflow job event") + + // Verify span event is present on the job span (first scope span) + resourceSpans := traces.ResourceSpans() + require.Equal(t, 1, resourceSpans.Len()) + + scopeSpans := resourceSpans.At(0).ScopeSpans() + require.Positive(t, scopeSpans.Len(), 0) + + // The job span is the first span + jobSpan := scopeSpans.At(0).Spans().At(0) + events := jobSpan.Events() + require.Equal(t, 1, events.Len(), "Expected one span event on job span") + + spanEvent := events.At(0) + require.Equal(t, "github.workflow_job.event", spanEvent.Name()) + + payload, exists := spanEvent.Attributes().Get("event.payload") + require.True(t, exists, "event.payload attribute should exist") + require.NotEmpty(t, payload.Str(), "event.payload should not be empty") + + // Verify the payload is valid JSON + var unmarshaled map[string]any + err = json.Unmarshal([]byte(payload.Str()), &unmarshaled) + require.NoError(t, err, "event.payload should be valid JSON") +} + +func TestHandleWorkflowRunWithoutSpanEvents(t *testing.T) { + config := createDefaultConfig().(*Config) + config.WebHook.Endpoint = "localhost:0" + // IncludeSpanEvents defaults to false + consumer := consumertest.NewNop() + + receiver, err := newTracesReceiver(receivertest.NewNopSettings(metadata.Type), config, consumer) + require.NoError(t, err, "failed to create receiver") + + testFilePath := filepath.Join("testdata", "workflow-run-completed.json") + data, err := os.ReadFile(testFilePath) + require.NoError(t, err, "Failed to read test data file") + + var event github.WorkflowRunEvent + err = json.Unmarshal(data, &event) + require.NoError(t, err, "Failed to unmarshal workflow run event") + + traces, err := receiver.handleWorkflowRun(&event, data) + require.NoError(t, err, "Failed to handle workflow run event") + + // Verify NO span events are present + resourceSpans := traces.ResourceSpans() + require.Equal(t, 1, resourceSpans.Len()) + + scopeSpans := resourceSpans.At(0).ScopeSpans() + require.Positive(t, scopeSpans.Len(), 0) + + spans := scopeSpans.At(0).Spans() + require.Positive(t, spans.Len(), 0) + + rootSpan := spans.At(0) + events := rootSpan.Events() + require.Equal(t, 0, events.Len(), "Expected no span events when disabled") +} + +func TestStepSpansHaveNoEvents(t *testing.T) { + config := createDefaultConfig().(*Config) + config.WebHook.Endpoint = "localhost:0" + config.WebHook.IncludeSpanEvents = true // Enable span events + consumer := consumertest.NewNop() + + receiver, err := newTracesReceiver(receivertest.NewNopSettings(metadata.Type), config, consumer) + require.NoError(t, err, "failed to create receiver") + + testFilePath := filepath.Join("testdata", "workflow-job-completed.json") + data, err := os.ReadFile(testFilePath) + require.NoError(t, err, "Failed to read test data file") + + var event github.WorkflowJobEvent + err = json.Unmarshal(data, &event) + require.NoError(t, err, "Failed to unmarshal workflow job event") + + traces, err := receiver.handleWorkflowJob(&event, data) + require.NoError(t, err, "Failed to handle workflow job event") + + // Verify step spans (not the first span) don't have events + resourceSpans := traces.ResourceSpans() + scopeSpans := resourceSpans.At(0).ScopeSpans() + + // Check spans beyond the first one (which is the job span) + // Queue span and step spans should have no events + for i := 1; i < scopeSpans.Len(); i++ { + spans := scopeSpans.At(i).Spans() + for j := 0; j < spans.Len(); j++ { + span := spans.At(j) + require.Equal(t, 0, span.Events().Len(), + "Step/queue span '%s' should not have events", span.Name()) + } + } +} diff --git a/receiver/githubreceiver/trace_receiver.go b/receiver/githubreceiver/trace_receiver.go index e8c23c65d219b..e48cf9994b79f 100644 --- a/receiver/githubreceiver/trace_receiver.go +++ b/receiver/githubreceiver/trace_receiver.go @@ -157,14 +157,14 @@ func (gtr *githubTracesReceiver) handleReq(w http.ResponseWriter, req *http.Requ w.WriteHeader(http.StatusNoContent) return } - td, err = gtr.handleWorkflowRun(e) + td, err = gtr.handleWorkflowRun(e, p) case *github.WorkflowJobEvent: if strings.ToLower(e.GetWorkflowJob().GetStatus()) != "completed" { gtr.logger.Debug("workflow job not complete, skipping...", zap.String("status", e.GetWorkflowJob().GetStatus())) w.WriteHeader(http.StatusNoContent) return } - td, err = gtr.handleWorkflowJob(e) + td, err = gtr.handleWorkflowJob(e, p) case *github.PingEvent: w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusOK)