diff --git a/ddtrace/tracer/civisibility_payload.go b/ddtrace/tracer/civisibility_payload.go new file mode 100644 index 0000000000..df8ffc04cc --- /dev/null +++ b/ddtrace/tracer/civisibility_payload.go @@ -0,0 +1,117 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "bytes" + "sync/atomic" + + "github.com/tinylib/msgp/msgp" + "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" + "gopkg.in/DataDog/dd-trace-go.v1/internal/version" +) + +// ciVisibilityPayload represents a payload specifically designed for CI Visibility events. +// It embeds the generic payload structure and adds methods to handle CI Visibility specific data. +type ciVisibilityPayload struct { + *payload +} + +// push adds a new CI Visibility event to the payload buffer. +// It grows the buffer to accommodate the new event, encodes the event in MessagePack format, and updates the event count. +// +// Parameters: +// +// event - The CI Visibility event to be added to the payload. +// +// Returns: +// +// An error if encoding the event fails. +func (p *ciVisibilityPayload) push(event *ciVisibilityEvent) error { + p.buf.Grow(event.Msgsize()) + if err := msgp.Encode(&p.buf, event); err != nil { + return err + } + atomic.AddUint32(&p.count, 1) + p.updateHeader() + return nil +} + +// newCiVisibilityPayload creates a new instance of civisibilitypayload. +// +// Returns: +// +// A pointer to a newly initialized civisibilitypayload instance. +func newCiVisibilityPayload() *ciVisibilityPayload { + return &ciVisibilityPayload{newPayload()} +} + +// getBuffer retrieves the complete body of the CI Visibility payload, including metadata. +// It reads the current payload buffer, adds metadata, and encodes the entire payload in MessagePack format. +// +// Parameters: +// +// config - A pointer to the config structure containing environment settings. +// +// Returns: +// +// A pointer to a bytes.Buffer containing the encoded CI Visibility payload. +// An error if reading from the buffer or encoding the payload fails. +func (p *ciVisibilityPayload) getBuffer(config *config) (*bytes.Buffer, error) { + + /* + The Payload format in the CI Visibility protocol is like this: + { + "version": 1, + "metadata": { + "*": { + "runtime-id": "...", + "language": "...", + "library_version": "...", + "env": "..." + } + }, + "events": [ + // ... + ] + } + + The event format can be found in the `civisibility_tslv.go` file in the ciVisibilityEvent documentation + */ + + // Create a buffer to read the current payload + payloadBuf := new(bytes.Buffer) + if _, err := payloadBuf.ReadFrom(p.payload); err != nil { + return nil, err + } + + // Create the metadata map + allMetadata := map[string]string{ + "language": "go", + "runtime-id": globalconfig.RuntimeID(), + "library_version": version.Tag, + } + if config.env != "" { + allMetadata["env"] = config.env + } + + // Create the visibility payload + visibilityPayload := ciTestCyclePayload{ + Version: 1, + Metadata: map[string]map[string]string{ + "*": allMetadata, + }, + Events: payloadBuf.Bytes(), + } + + // Create a new buffer to encode the visibility payload in MessagePack format + encodedBuf := new(bytes.Buffer) + if err := msgp.Encode(encodedBuf, &visibilityPayload); err != nil { + return nil, err + } + + return encodedBuf, nil +} diff --git a/ddtrace/tracer/civisibility_payload_test.go b/ddtrace/tracer/civisibility_payload_test.go new file mode 100644 index 0000000000..4057bb36e7 --- /dev/null +++ b/ddtrace/tracer/civisibility_payload_test.go @@ -0,0 +1,120 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "bytes" + "io" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tinylib/msgp/msgp" +) + +func newCiVisibilityEventsList(n int) []*ciVisibilityEvent { + list := make([]*ciVisibilityEvent, n) + for i := 0; i < n; i++ { + s := newBasicSpan("span.list." + strconv.Itoa(i%5+1)) + s.Start = fixedTime + list[i] = getCiVisibilityEvent(s) + } + + return list +} + +// TestCiVisibilityPayloadIntegrity tests that whatever we push into the payload +// allows us to read the same content as would have been encoded by +// the codec. +func TestCiVisibilityPayloadIntegrity(t *testing.T) { + want := new(bytes.Buffer) + for _, n := range []int{10, 1 << 10, 1 << 17} { + t.Run(strconv.Itoa(n), func(t *testing.T) { + assert := assert.New(t) + p := newCiVisibilityPayload() + var allEvents ciVisibilityEvents + + for i := 0; i < n; i++ { + list := newCiVisibilityEventsList(i%5 + 1) + allEvents = append(allEvents, list...) + for _, event := range list { + p.push(event) + } + } + + want.Reset() + err := msgp.Encode(want, allEvents) + assert.NoError(err) + assert.Equal(want.Len(), p.size()) + assert.Equal(p.itemCount(), len(allEvents)) + + got, err := io.ReadAll(p) + assert.NoError(err) + assert.Equal(want.Bytes(), got) + }) + } +} + +// TestCiVisibilityPayloadDecode ensures that whatever we push into the payload can +// be decoded by the codec. +func TestCiVisibilityPayloadDecode(t *testing.T) { + assert := assert.New(t) + for _, n := range []int{10, 1 << 10} { + t.Run(strconv.Itoa(n), func(t *testing.T) { + p := newCiVisibilityPayload() + for i := 0; i < n; i++ { + list := newCiVisibilityEventsList(i%5 + 1) + for _, event := range list { + p.push(event) + } + } + var got ciVisibilityEvents + err := msgp.Decode(p, &got) + assert.NoError(err) + }) + } +} + +func BenchmarkCiVisibilityPayloadThroughput(b *testing.B) { + b.Run("10K", benchmarkCiVisibilityPayloadThroughput(1)) + b.Run("100K", benchmarkCiVisibilityPayloadThroughput(10)) + b.Run("1MB", benchmarkCiVisibilityPayloadThroughput(100)) +} + +// benchmarkCiVisibilityPayloadThroughput benchmarks the throughput of the payload by subsequently +// pushing a list of civisibility events containing count spans of approximately 10KB in size each, until the +// payload is filled. +func benchmarkCiVisibilityPayloadThroughput(count int) func(*testing.B) { + return func(b *testing.B) { + p := newCiVisibilityPayload() + s := newBasicSpan("X") + s.Meta["key"] = strings.Repeat("X", 10*1024) + e := getCiVisibilityEvent(s) + events := make(ciVisibilityEvents, count) + for i := 0; i < count; i++ { + events[i] = e + } + + b.ReportAllocs() + b.ResetTimer() + reset := func() { + p.header = make([]byte, 8) + p.off = 8 + atomic.StoreUint32(&p.count, 0) + p.buf.Reset() + } + for i := 0; i < b.N; i++ { + reset() + for _, event := range events { + for p.size() < payloadMaxLimit { + p.push(event) + } + } + } + } +} diff --git a/ddtrace/tracer/civisibility_transport.go b/ddtrace/tracer/civisibility_transport.go new file mode 100644 index 0000000000..db64b5d73d --- /dev/null +++ b/ddtrace/tracer/civisibility_transport.go @@ -0,0 +1,200 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strconv" + "strings" + + "gopkg.in/DataDog/dd-trace-go.v1/internal" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/log" + "gopkg.in/DataDog/dd-trace-go.v1/internal/version" +) + +// Constants for CI Visibility API paths and subdomains. +const ( + TestCycleSubdomain = "citestcycle-intake" // Subdomain for test cycle intake. + TestCyclePath = "api/v2/citestcycle" // API path for test cycle. + EvpProxyPath = "evp_proxy/v2" // Path for EVP proxy. +) + +// Ensure that civisibilityTransport implements the transport interface. +var _ transport = (*ciVisibilityTransport)(nil) + +// ciVisibilityTransport is a structure that handles sending CI Visibility payloads +// to the Datadog endpoint, either in agentless mode or through the EVP proxy. +type ciVisibilityTransport struct { + config *config // Configuration for the tracer. + testCycleURLPath string // URL path for the test cycle endpoint. + headers map[string]string // HTTP headers to be included in the requests. + agentless bool // Gets if the transport is configured in agentless mode (eg: Gzip support) +} + +// newCiVisibilityTransport creates and initializes a new civisibilityTransport +// based on the provided tracer configuration. It sets up the appropriate headers +// and determines the URL path based on whether agentless mode is enabled. +// +// Parameters: +// +// config - The tracer configuration. +// +// Returns: +// +// A pointer to an initialized civisibilityTransport instance. +func newCiVisibilityTransport(config *config) *ciVisibilityTransport { + // Initialize the default headers with encoder metadata. + defaultHeaders := map[string]string{ + "Datadog-Meta-Lang": "go", + "Datadog-Meta-Lang-Version": strings.TrimPrefix(runtime.Version(), "go"), + "Datadog-Meta-Lang-Interpreter": runtime.Compiler + "-" + runtime.GOARCH + "-" + runtime.GOOS, + "Datadog-Meta-Tracer-Version": version.Tag, + "Content-Type": "application/msgpack", + } + if cid := internal.ContainerID(); cid != "" { + defaultHeaders["Datadog-Container-ID"] = cid + } + if eid := internal.EntityID(); eid != "" { + defaultHeaders["Datadog-Entity-ID"] = eid + } + + // Determine if agentless mode is enabled through an environment variable. + agentlessEnabled := internal.BoolEnv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, false) + + testCycleURL := "" + if agentlessEnabled { + // Agentless mode is enabled. + APIKeyValue := os.Getenv(constants.APIKeyEnvironmentVariable) + if APIKeyValue == "" { + log.Error("An API key is required for agentless mode. Use the DD_API_KEY env variable to set it") + } + + defaultHeaders["dd-api-key"] = APIKeyValue + + // Check for a custom agentless URL. + agentlessURL := "" + if v := os.Getenv(constants.CIVisibilityAgentlessURLEnvironmentVariable); v != "" { + agentlessURL = v + } + + if agentlessURL == "" { + // Use the standard agentless URL format. + site := "datadoghq.com" + if v := os.Getenv("DD_SITE"); v != "" { + site = v + } + + testCycleURL = fmt.Sprintf("https://%s.%s/%s", TestCycleSubdomain, site, TestCyclePath) + } else { + // Use the custom agentless URL. + testCycleURL = fmt.Sprintf("%s/%s", agentlessURL, TestCyclePath) + } + } else { + // Use agent mode with the EVP proxy. + defaultHeaders["X-Datadog-EVP-Subdomain"] = TestCycleSubdomain + testCycleURL = fmt.Sprintf("%s/%s/%s", config.agentURL.String(), EvpProxyPath, TestCyclePath) + } + + return &ciVisibilityTransport{ + config: config, + testCycleURLPath: testCycleURL, + headers: defaultHeaders, + agentless: agentlessEnabled, + } +} + +// send sends the CI Visibility payload to the Datadog endpoint. +// It prepares the payload, creates the HTTP request, and handles the response. +// +// Parameters: +// +// p - The payload to be sent. +// +// Returns: +// +// An io.ReadCloser for reading the response body, and an error if the operation fails. +func (t *ciVisibilityTransport) send(p *payload) (body io.ReadCloser, err error) { + ciVisibilityPayload := &ciVisibilityPayload{p} + buffer, bufferErr := ciVisibilityPayload.getBuffer(t.config) + if bufferErr != nil { + return nil, fmt.Errorf("cannot create buffer payload: %v", bufferErr) + } + + if t.agentless { + // Compress payload + var gzipBuffer bytes.Buffer + gzipWriter := gzip.NewWriter(&gzipBuffer) + _, err = io.Copy(gzipWriter, buffer) + if err != nil { + return nil, fmt.Errorf("cannot compress request body: %v", err) + } + err = gzipWriter.Close() + if err != nil { + return nil, fmt.Errorf("cannot compress request body: %v", err) + } + buffer = &gzipBuffer + } + + req, err := http.NewRequest("POST", t.testCycleURLPath, buffer) + if err != nil { + return nil, fmt.Errorf("cannot create http request: %v", err) + } + for header, value := range t.headers { + req.Header.Set(header, value) + } + req.Header.Set("Content-Length", strconv.Itoa(buffer.Len())) + if t.agentless { + req.Header.Set("Content-Encoding", "gzip") + } + + response, err := t.config.httpClient.Do(req) + if err != nil { + return nil, err + } + if code := response.StatusCode; code >= 400 { + // error, check the body for context information and + // return a nice error. + msg := make([]byte, 1000) + n, _ := response.Body.Read(msg) + _ = response.Body.Close() + txt := http.StatusText(code) + if n > 0 { + return nil, fmt.Errorf("%s (Status: %s)", msg[:n], txt) + } + return nil, fmt.Errorf("%s", txt) + } + return response.Body, nil +} + +// sendStats is a no-op for CI Visibility transport as it does not support sending stats payloads. +// +// Parameters: +// +// payload - The stats payload to be sent. +// +// Returns: +// +// An error indicating that stats are not supported. +func (t *ciVisibilityTransport) sendStats(*statsPayload) error { + // Stats are not supported by CI Visibility agentless / EVP proxy. + return nil +} + +// endpoint returns the URL path of the test cycle endpoint. +// +// Returns: +// +// The URL path as a string. +func (t *ciVisibilityTransport) endpoint() string { + return t.testCycleURLPath +} diff --git a/ddtrace/tracer/civisibility_transport_test.go b/ddtrace/tracer/civisibility_transport_test.go new file mode 100644 index 0000000000..72240f2c7e --- /dev/null +++ b/ddtrace/tracer/civisibility_transport_test.go @@ -0,0 +1,112 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tinylib/msgp/msgp" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" +) + +func TestCiVisibilityTransport(t *testing.T) { + t.Run("agentless", func(t *testing.T) { runTransportTest(t, true, true) }) + t.Run("agentless_no_api_key", func(t *testing.T) { runTransportTest(t, true, false) }) + t.Run("agentbased", func(t *testing.T) { runTransportTest(t, false, true) }) +} + +func runTransportTest(t *testing.T, agentless, shouldSetAPIKey bool) { + assert := assert.New(t) + + testCases := []struct { + payload [][]*span + }{ + {getTestTrace(1, 1)}, + {getTestTrace(10, 1)}, + {getTestTrace(100, 10)}, + } + + remainingEvents := 1000 + 10 + 1 + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + metaLang := r.Header.Get("Datadog-Meta-Lang") + assert.NotNil(metaLang) + + if agentless && shouldSetAPIKey { + apikey := r.Header.Get("dd-api-key") + assert.Equal("12345", apikey) + } + + contentType := r.Header.Get("Content-Type") + assert.Equal("application/msgpack", contentType) + + assert.True(strings.HasSuffix(r.RequestURI, TestCyclePath)) + + bodyBuffer := new(bytes.Buffer) + if r.Header.Get("Content-Encoding") == "gzip" { + gzipReader, err := gzip.NewReader(r.Body) + assert.NoError(err) + + _, err = bodyBuffer.ReadFrom(gzipReader) + assert.NoError(err) + } else { + _, err := bodyBuffer.ReadFrom(r.Body) + assert.NoError(err) + } + + var testCyclePayload ciTestCyclePayload + err := msgp.Decode(bodyBuffer, &testCyclePayload) + assert.NoError(err) + + var events ciVisibilityEvents + err = msgp.Decode(bytes.NewBuffer(testCyclePayload.Events), &events) + assert.NoError(err) + + remainingEvents = remainingEvents - len(events) + })) + defer srv.Close() + + parsedURL, _ := url.Parse(srv.URL) + c := config{ + ciVisibilityEnabled: true, + httpClient: defaultHTTPClient(0), + agentURL: parsedURL, + } + + // Set CI Visibility environment variables for the test + if agentless { + t.Setenv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, "1") + t.Setenv(constants.CIVisibilityAgentlessURLEnvironmentVariable, srv.URL) + if shouldSetAPIKey { + t.Setenv(constants.APIKeyEnvironmentVariable, "12345") + } + } + + for _, tc := range testCases { + transport := newCiVisibilityTransport(&c) + + p := newCiVisibilityPayload() + for _, t := range tc.payload { + for _, span := range t { + err := p.push(getCiVisibilityEvent(span)) + assert.NoError(err) + } + } + + _, err := transport.send(p.payload) + assert.NoError(err) + } + assert.Equal(hits, len(testCases)) + assert.Equal(remainingEvents, 0) +} diff --git a/ddtrace/tracer/civisibility_tslv.go b/ddtrace/tracer/civisibility_tslv.go new file mode 100644 index 0000000000..377f6d5656 --- /dev/null +++ b/ddtrace/tracer/civisibility_tslv.go @@ -0,0 +1,441 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +//go:generate msgp -unexported -marshal=false -o=civisibility_tslv_msgp.go -tests=false + +package tracer + +import ( + "strconv" + + "github.com/tinylib/msgp/msgp" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" +) + +type ( + // ciTestCyclePayloadList implements msgp.Decodable on top of a slice of ciVisibilityPayloads. + // This type is only used in tests. + ciTestCyclePayloadList []*ciTestCyclePayload + + // ciVisibilityEvents is a slice of ciVisibilityEvent pointers. + ciVisibilityEvents []*ciVisibilityEvent +) + +// Ensure that ciVisibilityEvent and related types implement necessary interfaces. +var ( + _ ddtrace.Span = (*ciVisibilityEvent)(nil) + _ msgp.Encodable = (*ciVisibilityEvent)(nil) + _ msgp.Decodable = (*ciVisibilityEvent)(nil) + + _ msgp.Encodable = (*ciVisibilityEvents)(nil) + _ msgp.Decodable = (*ciVisibilityEvents)(nil) + + _ msgp.Encodable = (*ciTestCyclePayload)(nil) + _ msgp.Decodable = (*ciTestCyclePayloadList)(nil) +) + +// ciTestCyclePayload represents the payload for CI test cycles, including version, metadata, and events. +type ciTestCyclePayload struct { + Version int32 `msg:"version"` // Version of the payload format + Metadata map[string]map[string]string `msg:"metadata"` // Metadata associated with the payload + Events msgp.Raw `msg:"events"` // Encoded events data +} + +// ciVisibilityEvent represents a CI visibility event, including type, version, and content. +// It implements the ddtrace.Span interface. +// According to the CI Visibility event specification it has the following format for tests: +// +// { +// "type": "test", +// "version": 2, +// "content": { +// "type": "test", +// "trace_id": 123456, +// "span_id": 654321, +// "parent_id": 0, +// "test_session_id": 123456789, +// "test_module_id": 234567890, +// "test_suite_id": 123123123, +// "name": "...", +// "resource": "...", +// "error": 0, +// "meta": { +// ... +// }, +// "metrics": { +// ... +// }, +// "start": 1654698415668011500, +// "duration": 796143, +// "service": "..." +// } +// } +// +// For test suites: +// +// { +// "type": "test_suite_end", +// "version": 1, +// "content": { +// "type": "test_suite_end", +// "test_module_id": 234567890, +// "test_session_id": 123456789, +// "test_suite_id": 123123123, +// "name": "...", +// "resource": "...", +// "error": 0, +// "meta": { +// ... +// }, +// "metrics": { +// ... +// }, +// "start": 1654698415668011500, +// "duration": 796143, +// "service": "..." +// } +// } +// +// For test modules: +// +// { +// "type": "test_module_end", +// "version": 1, +// "content": { +// "type": "test_module_end", +// "test_session_id": 123456789, +// "test_module_id": 234567890, +// "error": 0, +// "name": "...", +// "resource": "...", +// "meta": { +// ... +// }, +// "metrics": { +// ... +// }, +// "start": 1654698415668011500, +// "duration": 796143, +// "service": "..." +// } +// } +// +// For test sessions: +// +// { +// "type": "test_session_end", +// "version": 1, +// "content": { +// "type": "test_session_end", +// "test_session_id": 123456789, +// "name": "...", +// "resource": "...", +// "error": 0, +// "meta": { +// ... +// }, +// "metrics": { +// ... +// }, +// "start": 1654698415668011500, +// "duration": 796143, +// "service": "..." +// } +// } +// +// A complete specification for the meta and metrics maps for each type can be found at: https://github.com/DataDog/datadog-ci-spec/tree/main/spec/citest +type ciVisibilityEvent struct { + Type string `msg:"type"` // Type of the CI visibility event + Version int32 `msg:"version"` // Version of the event type + Content tslvSpan `msg:"content"` // Content of the event + + span *span `msg:"-"` // Associated span (not marshaled) +} + +// SetTag sets a tag on the event's span and updates the content metadata and metrics. +// +// Parameters: +// +// key - The tag key. +// value - The tag value. +func (e *ciVisibilityEvent) SetTag(key string, value interface{}) { + e.span.SetTag(key, value) + e.Content.Meta = e.span.Meta + e.Content.Metrics = e.span.Metrics +} + +// SetOperationName sets the operation name of the event's span and updates the content name. +// +// Parameters: +// +// operationName - The new operation name. +func (e *ciVisibilityEvent) SetOperationName(operationName string) { + e.span.SetOperationName(operationName) + e.Content.Name = e.span.Name +} + +// BaggageItem retrieves the baggage item associated with the given key from the event's span. +// +// Parameters: +// +// key - The baggage item key. +// +// Returns: +// +// The baggage item value. +func (e *ciVisibilityEvent) BaggageItem(key string) string { + return e.span.BaggageItem(key) +} + +// SetBaggageItem sets a baggage item on the event's span. +// +// Parameters: +// +// key - The baggage item key. +// val - The baggage item value. +func (e *ciVisibilityEvent) SetBaggageItem(key, val string) { + e.span.SetBaggageItem(key, val) +} + +// Finish completes the event's span with optional finish options. +// +// Parameters: +// +// opts - Optional finish options. +func (e *ciVisibilityEvent) Finish(opts ...ddtrace.FinishOption) { + e.span.Finish(opts...) +} + +// Context returns the span context of the event's span. +// +// Returns: +// +// The span context. +func (e *ciVisibilityEvent) Context() ddtrace.SpanContext { + return e.span.Context() +} + +// tslvSpan represents the detailed information of a span for CI visibility. +type tslvSpan struct { + SessionID uint64 `msg:"test_session_id,omitempty"` // identifier of this session + ModuleID uint64 `msg:"test_module_id,omitempty"` // identifier of this module + SuiteID uint64 `msg:"test_suite_id,omitempty"` // identifier of this suite + CorrelationID string `msg:"itr_correlation_id,omitempty"` // Correlation Id for Intelligent Test Runner transactions + Name string `msg:"name"` // operation name + Service string `msg:"service"` // service name (i.e. "grpc.server", "http.request") + Resource string `msg:"resource"` // resource name (i.e. "/user?id=123", "SELECT * FROM users") + Type string `msg:"type"` // protocol associated with the span (i.e. "web", "db", "cache") + Start int64 `msg:"start"` // span start time expressed in nanoseconds since epoch + Duration int64 `msg:"duration"` // duration of the span expressed in nanoseconds + SpanID uint64 `msg:"span_id,omitempty"` // identifier of this span + TraceID uint64 `msg:"trace_id,omitempty"` // lower 64-bits of the root span identifier + ParentID uint64 `msg:"parent_id,omitempty"` // identifier of the span's direct parent + Error int32 `msg:"error"` // error status of the span; 0 means no errors + Meta map[string]string `msg:"meta,omitempty"` // arbitrary map of metadata + Metrics map[string]float64 `msg:"metrics,omitempty"` // arbitrary map of numeric metrics +} + +// getCiVisibilityEvent creates a ciVisibilityEvent from a span based on the span type. +// +// Parameters: +// +// span - The span to convert into a ciVisibilityEvent. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func getCiVisibilityEvent(span *span) *ciVisibilityEvent { + switch span.Type { + case constants.SpanTypeTest: + return createTestEventFromSpan(span) + case constants.SpanTypeTestSuite: + return createTestSuiteEventFromSpan(span) + case constants.SpanTypeTestModule: + return createTestModuleEventFromSpan(span) + case constants.SpanTypeTestSession: + return createTestSessionEventFromSpan(span) + default: + return createSpanEventFromSpan(span) + } +} + +// createTestEventFromSpan creates a ciVisibilityEvent of type Test from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func createTestEventFromSpan(span *span) *ciVisibilityEvent { + tSpan := createTslvSpan(span) + tSpan.SessionID = getAndRemoveMetaToUInt64(span, constants.TestSessionIDTag) + tSpan.ModuleID = getAndRemoveMetaToUInt64(span, constants.TestModuleIDTag) + tSpan.SuiteID = getAndRemoveMetaToUInt64(span, constants.TestSuiteIDTag) + tSpan.CorrelationID = getAndRemoveMeta(span, constants.ItrCorrelationIDTag) + tSpan.SpanID = span.SpanID + tSpan.TraceID = span.TraceID + return &ciVisibilityEvent{ + span: span, + Type: constants.SpanTypeTest, + Version: 2, + Content: tSpan, + } +} + +// createTestSuiteEventFromSpan creates a ciVisibilityEvent of type TestSuite from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func createTestSuiteEventFromSpan(span *span) *ciVisibilityEvent { + tSpan := createTslvSpan(span) + tSpan.SessionID = getAndRemoveMetaToUInt64(span, constants.TestSessionIDTag) + tSpan.ModuleID = getAndRemoveMetaToUInt64(span, constants.TestModuleIDTag) + tSpan.SuiteID = getAndRemoveMetaToUInt64(span, constants.TestSuiteIDTag) + return &ciVisibilityEvent{ + span: span, + Type: constants.SpanTypeTestSuite, + Version: 1, + Content: tSpan, + } +} + +// createTestModuleEventFromSpan creates a ciVisibilityEvent of type TestModule from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func createTestModuleEventFromSpan(span *span) *ciVisibilityEvent { + tSpan := createTslvSpan(span) + tSpan.SessionID = getAndRemoveMetaToUInt64(span, constants.TestSessionIDTag) + tSpan.ModuleID = getAndRemoveMetaToUInt64(span, constants.TestModuleIDTag) + return &ciVisibilityEvent{ + span: span, + Type: constants.SpanTypeTestModule, + Version: 1, + Content: tSpan, + } +} + +// createTestSessionEventFromSpan creates a ciVisibilityEvent of type TestSession from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func createTestSessionEventFromSpan(span *span) *ciVisibilityEvent { + tSpan := createTslvSpan(span) + tSpan.SessionID = getAndRemoveMetaToUInt64(span, constants.TestSessionIDTag) + return &ciVisibilityEvent{ + span: span, + Type: constants.SpanTypeTestSession, + Version: 1, + Content: tSpan, + } +} + +// createSpanEventFromSpan creates a ciVisibilityEvent of type Span from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// A pointer to the created ciVisibilityEvent. +func createSpanEventFromSpan(span *span) *ciVisibilityEvent { + tSpan := createTslvSpan(span) + tSpan.SpanID = span.SpanID + tSpan.TraceID = span.TraceID + return &ciVisibilityEvent{ + span: span, + Type: constants.SpanTypeSpan, + Version: 1, + Content: tSpan, + } +} + +// createTslvSpan creates a tslvSpan from a span. +// +// Parameters: +// +// span - The span to convert. +// +// Returns: +// +// The created tslvSpan. +func createTslvSpan(span *span) tslvSpan { + return tslvSpan{ + Name: span.Name, + Service: span.Service, + Resource: span.Resource, + Type: span.Type, + Start: span.Start, + Duration: span.Duration, + ParentID: span.ParentID, + Error: span.Error, + Meta: span.Meta, + Metrics: span.Metrics, + } +} + +// getAndRemoveMeta retrieves a metadata value from a span and removes it from the span's metadata and metrics. +// +// Parameters: +// +// span - The span to modify. +// key - The metadata key to retrieve and remove. +// +// Returns: +// +// The retrieved metadata value. +func getAndRemoveMeta(span *span, key string) string { + span.Lock() + defer span.Unlock() + if span.Meta == nil { + span.Meta = make(map[string]string, 1) + } + + if v, ok := span.Meta[key]; ok { + delete(span.Meta, key) + delete(span.Metrics, key) + return v + } + + return "" +} + +// getAndRemoveMetaToUInt64 retrieves a metadata value from a span, removes it, and converts it to a uint64. +// +// Parameters: +// +// span - The span to modify. +// key - The metadata key to retrieve and convert. +// +// Returns: +// +// The retrieved and converted metadata value as a uint64. +func getAndRemoveMetaToUInt64(span *span, key string) uint64 { + strValue := getAndRemoveMeta(span, key) + i, err := strconv.ParseUint(strValue, 10, 64) + if err != nil { + return 0 + } + return i +} diff --git a/ddtrace/tracer/civisibility_tslv_msgp.go b/ddtrace/tracer/civisibility_tslv_msgp.go new file mode 100644 index 0000000000..63fa4b8499 --- /dev/null +++ b/ddtrace/tracer/civisibility_tslv_msgp.go @@ -0,0 +1,924 @@ +package tracer + +// Code generated by github.com/tinylib/msgp DO NOT EDIT. + +import ( + "github.com/tinylib/msgp/msgp" +) + +// DecodeMsg implements msgp.Decodable +func (z *ciTestCyclePayload) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "version": + z.Version, err = dc.ReadInt32() + if err != nil { + err = msgp.WrapError(err, "Version") + return + } + case "metadata": + var zb0002 uint32 + zb0002, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Metadata") + return + } + if z.Metadata == nil { + z.Metadata = make(map[string]map[string]string, zb0002) + } else if len(z.Metadata) > 0 { + for key := range z.Metadata { + delete(z.Metadata, key) + } + } + for zb0002 > 0 { + zb0002-- + var za0001 string + var za0002 map[string]string + za0001, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Metadata") + return + } + var zb0003 uint32 + zb0003, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001) + return + } + if za0002 == nil { + za0002 = make(map[string]string, zb0003) + } else if len(za0002) > 0 { + for key := range za0002 { + delete(za0002, key) + } + } + for zb0003 > 0 { + zb0003-- + var za0003 string + var za0004 string + za0003, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001) + return + } + za0004, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001, za0003) + return + } + za0002[za0003] = za0004 + } + z.Metadata[za0001] = za0002 + } + case "events": + err = z.Events.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Events") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *ciTestCyclePayload) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 3 + // write "version" + err = en.Append(0x83, 0xa7, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e) + if err != nil { + return + } + err = en.WriteInt32(z.Version) + if err != nil { + err = msgp.WrapError(err, "Version") + return + } + // write "metadata" + err = en.Append(0xa8, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61) + if err != nil { + return + } + err = en.WriteMapHeader(uint32(len(z.Metadata))) + if err != nil { + err = msgp.WrapError(err, "Metadata") + return + } + for za0001, za0002 := range z.Metadata { + err = en.WriteString(za0001) + if err != nil { + err = msgp.WrapError(err, "Metadata") + return + } + err = en.WriteMapHeader(uint32(len(za0002))) + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001) + return + } + for za0003, za0004 := range za0002 { + err = en.WriteString(za0003) + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001) + return + } + err = en.WriteString(za0004) + if err != nil { + err = msgp.WrapError(err, "Metadata", za0001, za0003) + return + } + } + } + // write "events" + err = en.Append(0xa6, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73) + if err != nil { + return + } + err = z.Events.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "Events") + return + } + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *ciTestCyclePayload) Msgsize() (s int) { + s = 1 + 8 + msgp.Int32Size + 9 + msgp.MapHeaderSize + if z.Metadata != nil { + for za0001, za0002 := range z.Metadata { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + msgp.MapHeaderSize + if za0002 != nil { + for za0003, za0004 := range za0002 { + _ = za0004 + s += msgp.StringPrefixSize + len(za0003) + msgp.StringPrefixSize + len(za0004) + } + } + } + } + s += 7 + z.Events.Msgsize() + return +} + +// DecodeMsg implements msgp.Decodable +func (z *ciTestCyclePayloadList) DecodeMsg(dc *msgp.Reader) (err error) { + var zb0002 uint32 + zb0002, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + if cap((*z)) >= int(zb0002) { + (*z) = (*z)[:zb0002] + } else { + (*z) = make(ciTestCyclePayloadList, zb0002) + } + for zb0001 := range *z { + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + (*z)[zb0001] = nil + } else { + if (*z)[zb0001] == nil { + (*z)[zb0001] = new(ciTestCyclePayload) + } + err = (*z)[zb0001].DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z ciTestCyclePayloadList) EncodeMsg(en *msgp.Writer) (err error) { + err = en.WriteArrayHeader(uint32(len(z))) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0003 := range z { + if z[zb0003] == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + err = z[zb0003].EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, zb0003) + return + } + } + } + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z ciTestCyclePayloadList) Msgsize() (s int) { + s = msgp.ArrayHeaderSize + for zb0003 := range z { + if z[zb0003] == nil { + s += msgp.NilSize + } else { + s += z[zb0003].Msgsize() + } + } + return +} + +// DecodeMsg implements msgp.Decodable +func (z *ciVisibilityEvent) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "type": + z.Type, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Type") + return + } + case "version": + z.Version, err = dc.ReadInt32() + if err != nil { + err = msgp.WrapError(err, "Version") + return + } + case "content": + err = z.Content.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, "Content") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *ciVisibilityEvent) EncodeMsg(en *msgp.Writer) (err error) { + // map header, size 3 + // write "type" + err = en.Append(0x83, 0xa4, 0x74, 0x79, 0x70, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Type) + if err != nil { + err = msgp.WrapError(err, "Type") + return + } + // write "version" + err = en.Append(0xa7, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e) + if err != nil { + return + } + err = en.WriteInt32(z.Version) + if err != nil { + err = msgp.WrapError(err, "Version") + return + } + // write "content" + err = en.Append(0xa7, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74) + if err != nil { + return + } + err = z.Content.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, "Content") + return + } + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *ciVisibilityEvent) Msgsize() (s int) { + s = 1 + 5 + msgp.StringPrefixSize + len(z.Type) + 8 + msgp.Int32Size + 8 + z.Content.Msgsize() + return +} + +// DecodeMsg implements msgp.Decodable +func (z *ciVisibilityEvents) DecodeMsg(dc *msgp.Reader) (err error) { + var zb0002 uint32 + zb0002, err = dc.ReadArrayHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + if cap((*z)) >= int(zb0002) { + (*z) = (*z)[:zb0002] + } else { + (*z) = make(ciVisibilityEvents, zb0002) + } + for zb0001 := range *z { + if dc.IsNil() { + err = dc.ReadNil() + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + (*z)[zb0001] = nil + } else { + if (*z)[zb0001] == nil { + (*z)[zb0001] = new(ciVisibilityEvent) + } + var field []byte + _ = field + var zb0003 uint32 + zb0003, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + for zb0003 > 0 { + zb0003-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + switch msgp.UnsafeString(field) { + case "type": + (*z)[zb0001].Type, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, zb0001, "Type") + return + } + case "version": + (*z)[zb0001].Version, err = dc.ReadInt32() + if err != nil { + err = msgp.WrapError(err, zb0001, "Version") + return + } + case "content": + err = (*z)[zb0001].Content.DecodeMsg(dc) + if err != nil { + err = msgp.WrapError(err, zb0001, "Content") + return + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err, zb0001) + return + } + } + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z ciVisibilityEvents) EncodeMsg(en *msgp.Writer) (err error) { + err = en.WriteArrayHeader(uint32(len(z))) + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0004 := range z { + if z[zb0004] == nil { + err = en.WriteNil() + if err != nil { + return + } + } else { + // map header, size 3 + // write "type" + err = en.Append(0x83, 0xa4, 0x74, 0x79, 0x70, 0x65) + if err != nil { + return + } + err = en.WriteString(z[zb0004].Type) + if err != nil { + err = msgp.WrapError(err, zb0004, "Type") + return + } + // write "version" + err = en.Append(0xa7, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e) + if err != nil { + return + } + err = en.WriteInt32(z[zb0004].Version) + if err != nil { + err = msgp.WrapError(err, zb0004, "Version") + return + } + // write "content" + err = en.Append(0xa7, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74) + if err != nil { + return + } + err = z[zb0004].Content.EncodeMsg(en) + if err != nil { + err = msgp.WrapError(err, zb0004, "Content") + return + } + } + } + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z ciVisibilityEvents) Msgsize() (s int) { + s = msgp.ArrayHeaderSize + for zb0004 := range z { + if z[zb0004] == nil { + s += msgp.NilSize + } else { + s += 1 + 5 + msgp.StringPrefixSize + len(z[zb0004].Type) + 8 + msgp.Int32Size + 8 + z[zb0004].Content.Msgsize() + } + } + return +} + +// DecodeMsg implements msgp.Decodable +func (z *tslvSpan) DecodeMsg(dc *msgp.Reader) (err error) { + var field []byte + _ = field + var zb0001 uint32 + zb0001, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err) + return + } + for zb0001 > 0 { + zb0001-- + field, err = dc.ReadMapKeyPtr() + if err != nil { + err = msgp.WrapError(err) + return + } + switch msgp.UnsafeString(field) { + case "test_session_id": + z.SessionID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "SessionId") + return + } + case "test_module_id": + z.ModuleID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "ModuleId") + return + } + case "test_suite_id": + z.SuiteID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "SuiteId") + return + } + case "itr_correlation_id": + z.CorrelationID, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "CorrelationId") + return + } + case "name": + z.Name, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Name") + return + } + case "service": + z.Service, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Service") + return + } + case "resource": + z.Resource, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Resource") + return + } + case "type": + z.Type, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Type") + return + } + case "start": + z.Start, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "Start") + return + } + case "duration": + z.Duration, err = dc.ReadInt64() + if err != nil { + err = msgp.WrapError(err, "Duration") + return + } + case "span_id": + z.SpanID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "SpanID") + return + } + case "trace_id": + z.TraceID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "TraceID") + return + } + case "parent_id": + z.ParentID, err = dc.ReadUint64() + if err != nil { + err = msgp.WrapError(err, "ParentID") + return + } + case "error": + z.Error, err = dc.ReadInt32() + if err != nil { + err = msgp.WrapError(err, "Error") + return + } + case "meta": + var zb0002 uint32 + zb0002, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Meta") + return + } + if z.Meta == nil { + z.Meta = make(map[string]string, zb0002) + } else if len(z.Meta) > 0 { + for key := range z.Meta { + delete(z.Meta, key) + } + } + for zb0002 > 0 { + zb0002-- + var za0001 string + var za0002 string + za0001, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Meta") + return + } + za0002, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Meta", za0001) + return + } + z.Meta[za0001] = za0002 + } + case "metrics": + var zb0003 uint32 + zb0003, err = dc.ReadMapHeader() + if err != nil { + err = msgp.WrapError(err, "Metrics") + return + } + if z.Metrics == nil { + z.Metrics = make(map[string]float64, zb0003) + } else if len(z.Metrics) > 0 { + for key := range z.Metrics { + delete(z.Metrics, key) + } + } + for zb0003 > 0 { + zb0003-- + var za0003 string + var za0004 float64 + za0003, err = dc.ReadString() + if err != nil { + err = msgp.WrapError(err, "Metrics") + return + } + za0004, err = dc.ReadFloat64() + if err != nil { + err = msgp.WrapError(err, "Metrics", za0003) + return + } + z.Metrics[za0003] = za0004 + } + default: + err = dc.Skip() + if err != nil { + err = msgp.WrapError(err) + return + } + } + } + return +} + +// EncodeMsg implements msgp.Encodable +func (z *tslvSpan) EncodeMsg(en *msgp.Writer) (err error) { + // omitempty: check for empty values + zb0001Len := uint32(16) + var zb0001Mask uint16 /* 16 bits */ + _ = zb0001Mask + if z.SessionID == 0 { + zb0001Len-- + zb0001Mask |= 0x1 + } + if z.ModuleID == 0 { + zb0001Len-- + zb0001Mask |= 0x2 + } + if z.SuiteID == 0 { + zb0001Len-- + zb0001Mask |= 0x4 + } + if z.CorrelationID == "" { + zb0001Len-- + zb0001Mask |= 0x8 + } + if z.SpanID == 0 { + zb0001Len-- + zb0001Mask |= 0x400 + } + if z.TraceID == 0 { + zb0001Len-- + zb0001Mask |= 0x800 + } + if z.ParentID == 0 { + zb0001Len-- + zb0001Mask |= 0x1000 + } + if z.Meta == nil { + zb0001Len-- + zb0001Mask |= 0x4000 + } + if z.Metrics == nil { + zb0001Len-- + zb0001Mask |= 0x8000 + } + // variable map header, size zb0001Len + err = en.WriteMapHeader(zb0001Len) + if err != nil { + return + } + if zb0001Len == 0 { + return + } + if (zb0001Mask & 0x1) == 0 { // if not empty + // write "test_session_id" + err = en.Append(0xaf, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.SessionID) + if err != nil { + err = msgp.WrapError(err, "SessionID") + return + } + } + if (zb0001Mask & 0x2) == 0 { // if not empty + // write "test_module_id" + err = en.Append(0xae, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.ModuleID) + if err != nil { + err = msgp.WrapError(err, "ModuleID") + return + } + } + if (zb0001Mask & 0x4) == 0 { // if not empty + // write "test_suite_id" + err = en.Append(0xad, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x73, 0x75, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.SuiteID) + if err != nil { + err = msgp.WrapError(err, "SuiteID") + return + } + } + if (zb0001Mask & 0x8) == 0 { // if not empty + // write "itr_correlation_id" + err = en.Append(0xb2, 0x69, 0x74, 0x72, 0x5f, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteString(z.CorrelationID) + if err != nil { + err = msgp.WrapError(err, "CorrelationID") + return + } + } + // write "name" + err = en.Append(0xa4, 0x6e, 0x61, 0x6d, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Name) + if err != nil { + err = msgp.WrapError(err, "Name") + return + } + // write "service" + err = en.Append(0xa7, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Service) + if err != nil { + err = msgp.WrapError(err, "Service") + return + } + // write "resource" + err = en.Append(0xa8, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Resource) + if err != nil { + err = msgp.WrapError(err, "Resource") + return + } + // write "type" + err = en.Append(0xa4, 0x74, 0x79, 0x70, 0x65) + if err != nil { + return + } + err = en.WriteString(z.Type) + if err != nil { + err = msgp.WrapError(err, "Type") + return + } + // write "start" + err = en.Append(0xa5, 0x73, 0x74, 0x61, 0x72, 0x74) + if err != nil { + return + } + err = en.WriteInt64(z.Start) + if err != nil { + err = msgp.WrapError(err, "Start") + return + } + // write "duration" + err = en.Append(0xa8, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e) + if err != nil { + return + } + err = en.WriteInt64(z.Duration) + if err != nil { + err = msgp.WrapError(err, "Duration") + return + } + if (zb0001Mask & 0x400) == 0 { // if not empty + // write "span_id" + err = en.Append(0xa7, 0x73, 0x70, 0x61, 0x6e, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.SpanID) + if err != nil { + err = msgp.WrapError(err, "SpanID") + return + } + } + if (zb0001Mask & 0x800) == 0 { // if not empty + // write "trace_id" + err = en.Append(0xa8, 0x74, 0x72, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.TraceID) + if err != nil { + err = msgp.WrapError(err, "TraceID") + return + } + } + if (zb0001Mask & 0x1000) == 0 { // if not empty + // write "parent_id" + err = en.Append(0xa9, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64) + if err != nil { + return + } + err = en.WriteUint64(z.ParentID) + if err != nil { + err = msgp.WrapError(err, "ParentID") + return + } + } + // write "error" + err = en.Append(0xa5, 0x65, 0x72, 0x72, 0x6f, 0x72) + if err != nil { + return + } + err = en.WriteInt32(z.Error) + if err != nil { + err = msgp.WrapError(err, "Error") + return + } + if (zb0001Mask & 0x4000) == 0 { // if not empty + // write "meta" + err = en.Append(0xa4, 0x6d, 0x65, 0x74, 0x61) + if err != nil { + return + } + err = en.WriteMapHeader(uint32(len(z.Meta))) + if err != nil { + err = msgp.WrapError(err, "Meta") + return + } + for za0001, za0002 := range z.Meta { + err = en.WriteString(za0001) + if err != nil { + err = msgp.WrapError(err, "Meta") + return + } + err = en.WriteString(za0002) + if err != nil { + err = msgp.WrapError(err, "Meta", za0001) + return + } + } + } + if (zb0001Mask & 0x8000) == 0 { // if not empty + // write "metrics" + err = en.Append(0xa7, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73) + if err != nil { + return + } + err = en.WriteMapHeader(uint32(len(z.Metrics))) + if err != nil { + err = msgp.WrapError(err, "Metrics") + return + } + for za0003, za0004 := range z.Metrics { + err = en.WriteString(za0003) + if err != nil { + err = msgp.WrapError(err, "Metrics") + return + } + err = en.WriteFloat64(za0004) + if err != nil { + err = msgp.WrapError(err, "Metrics", za0003) + return + } + } + } + return +} + +// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message +func (z *tslvSpan) Msgsize() (s int) { + s = 3 + 16 + msgp.Uint64Size + 15 + msgp.Uint64Size + 14 + msgp.Uint64Size + 19 + msgp.StringPrefixSize + len(z.CorrelationID) + 5 + msgp.StringPrefixSize + len(z.Name) + 8 + msgp.StringPrefixSize + len(z.Service) + 9 + msgp.StringPrefixSize + len(z.Resource) + 5 + msgp.StringPrefixSize + len(z.Type) + 6 + msgp.Int64Size + 9 + msgp.Int64Size + 8 + msgp.Uint64Size + 9 + msgp.Uint64Size + 10 + msgp.Uint64Size + 6 + msgp.Int32Size + 5 + msgp.MapHeaderSize + if z.Meta != nil { + for za0001, za0002 := range z.Meta { + _ = za0002 + s += msgp.StringPrefixSize + len(za0001) + msgp.StringPrefixSize + len(za0002) + } + } + s += 8 + msgp.MapHeaderSize + if z.Metrics != nil { + for za0003, za0004 := range z.Metrics { + _ = za0004 + s += msgp.StringPrefixSize + len(za0003) + msgp.Float64Size + } + } + return +} diff --git a/ddtrace/tracer/civisibility_writer.go b/ddtrace/tracer/civisibility_writer.go new file mode 100644 index 0000000000..1582b200a8 --- /dev/null +++ b/ddtrace/tracer/civisibility_writer.go @@ -0,0 +1,119 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "sync" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/internal/log" +) + +// Constants defining the payload size limits for agentless mode. +const ( + // agentlessPayloadMaxLimit is the maximum payload size allowed, indicating the + // maximum size of the package that the intake can receive. + agentlessPayloadMaxLimit = 5 * 1024 * 1024 // 5 MB + + // agentlessPayloadSizeLimit specifies the maximum allowed size of the payload before + // it triggers a flush to the transport. + agentlessPayloadSizeLimit = agentlessPayloadMaxLimit / 2 +) + +// Ensure that ciVisibilityTraceWriter implements the traceWriter interface. +var _ traceWriter = (*ciVisibilityTraceWriter)(nil) + +// ciVisibilityTraceWriter is responsible for buffering and sending CI visibility trace data +// to the Datadog backend. It manages the payload size and flushes the data when necessary. +type ciVisibilityTraceWriter struct { + config *config // Configuration for the tracer. + payload *ciVisibilityPayload // Encodes and buffers events in msgpack format. + climit chan struct{} // Limits the number of concurrent outgoing connections. + wg sync.WaitGroup // Waits for all uploads to finish. +} + +// newCiVisibilityTraceWriter creates a new instance of ciVisibilityTraceWriter. +// +// Parameters: +// +// c - The tracer configuration. +// +// Returns: +// +// A pointer to an initialized ciVisibilityTraceWriter. +func newCiVisibilityTraceWriter(c *config) *ciVisibilityTraceWriter { + return &ciVisibilityTraceWriter{ + config: c, + payload: newCiVisibilityPayload(), + climit: make(chan struct{}, concurrentConnectionLimit), + } +} + +// add adds a new trace to the payload. If the payload size exceeds the limit, +// it triggers a flush to send the data. +// +// Parameters: +// +// trace - A slice of spans representing the trace to be added. +func (w *ciVisibilityTraceWriter) add(trace []*span) { + for _, s := range trace { + cvEvent := getCiVisibilityEvent(s) + if err := w.payload.push(cvEvent); err != nil { + log.Error("Error encoding msgpack: %v", err) + } + if w.payload.size() > agentlessPayloadSizeLimit { + w.flush() + } + } +} + +// stop stops the trace writer, ensuring all data is flushed and all uploads are completed. +func (w *ciVisibilityTraceWriter) stop() { + w.flush() + w.wg.Wait() +} + +// flush sends the current payload to the transport. It ensures that the payload is reset +// and the resources are freed after the flush operation is completed. +func (w *ciVisibilityTraceWriter) flush() { + if w.payload.itemCount() == 0 { + return + } + + w.wg.Add(1) + w.climit <- struct{}{} + oldp := w.payload + w.payload = newCiVisibilityPayload() + + go func(p *ciVisibilityPayload) { + defer func(start time.Time) { + // Once the payload has been used, clear the buffer for garbage + // collection to avoid a memory leak when references to this object + // may still be kept by faulty transport implementations or the + // standard library. See dd-trace-go#976 + p.clear() + + <-w.climit + w.wg.Done() + }(time.Now()) + + var count, size int + var err error + for attempt := 0; attempt <= w.config.sendRetries; attempt++ { + size, count = p.size(), p.itemCount() + log.Debug("Sending payload: size: %d events: %d\n", size, count) + _, err = w.config.transport.send(p.payload) + if err == nil { + log.Debug("sent events after %d attempts", attempt+1) + return + } + log.Error("failure sending events (attempt %d), will retry: %v", attempt+1, err) + p.reset() + time.Sleep(time.Millisecond) + } + log.Error("lost %d events: %v", count, err) + }(oldp) +} diff --git a/ddtrace/tracer/civisibility_writer_test.go b/ddtrace/tracer/civisibility_writer_test.go new file mode 100644 index 0000000000..0757ec05bb --- /dev/null +++ b/ddtrace/tracer/civisibility_writer_test.go @@ -0,0 +1,101 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package tracer + +import ( + "errors" + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tinylib/msgp/msgp" +) + +func TestCIVisibilityImplementsTraceWriter(t *testing.T) { + assert.Implements(t, (*traceWriter)(nil), &ciVisibilityTraceWriter{}) +} + +type failingCiVisibilityTransport struct { + dummyTransport + failCount int + sendAttempts int + tracesSent bool + events ciVisibilityEvents + assert *assert.Assertions +} + +func (t *failingCiVisibilityTransport) send(p *payload) (io.ReadCloser, error) { + t.sendAttempts++ + + ciVisibilityPayload := &ciVisibilityPayload{p} + + var events ciVisibilityEvents + err := msgp.Decode(ciVisibilityPayload, &events) + if err != nil { + return nil, err + } + if t.sendAttempts == 1 { + t.events = events + } else { + t.assert.Equal(t.events, events) + } + + if t.failCount > 0 { + t.failCount-- + return nil, errors.New("oops, I failed") + } + + t.tracesSent = true + return io.NopCloser(strings.NewReader("OK")), nil +} + +func TestCiVisibilityTraceWriterFlushRetries(t *testing.T) { + testcases := []struct { + configRetries int + failCount int + tracesSent bool + expAttempts int + }{ + {configRetries: 0, failCount: 0, tracesSent: true, expAttempts: 1}, + {configRetries: 0, failCount: 1, tracesSent: false, expAttempts: 1}, + + {configRetries: 1, failCount: 0, tracesSent: true, expAttempts: 1}, + {configRetries: 1, failCount: 1, tracesSent: true, expAttempts: 2}, + {configRetries: 1, failCount: 2, tracesSent: false, expAttempts: 2}, + + {configRetries: 2, failCount: 0, tracesSent: true, expAttempts: 1}, + {configRetries: 2, failCount: 1, tracesSent: true, expAttempts: 2}, + {configRetries: 2, failCount: 2, tracesSent: true, expAttempts: 3}, + {configRetries: 2, failCount: 3, tracesSent: false, expAttempts: 3}, + } + + ss := []*span{makeSpan(0)} + for _, test := range testcases { + name := fmt.Sprintf("%d-%d-%t-%d", test.configRetries, test.failCount, test.tracesSent, test.expAttempts) + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + p := &failingCiVisibilityTransport{ + failCount: test.failCount, + assert: assert, + } + c := newConfig(func(c *config) { + c.transport = p + c.sendRetries = test.configRetries + }) + + h := newCiVisibilityTraceWriter(c) + h.add(ss) + + h.flush() + h.wg.Wait() + + assert.Equal(test.expAttempts, p.sendAttempts) + assert.Equal(test.tracesSent, p.tracesSent) + }) + } +} diff --git a/ddtrace/tracer/option.go b/ddtrace/tracer/option.go index 68de8390ef..e4d7f2a6e6 100644 --- a/ddtrace/tracer/option.go +++ b/ddtrace/tracer/option.go @@ -25,6 +25,7 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" "gopkg.in/DataDog/dd-trace-go.v1/internal" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" "gopkg.in/DataDog/dd-trace-go.v1/internal/namingschema" @@ -274,6 +275,9 @@ type config struct { // globalSampleRate holds sample rate read from environment variables. globalSampleRate float64 + + // ciVisibilityEnabled controls if the tracer is loaded with CI Visibility mode. default false + ciVisibilityEnabled bool } // orchestrionConfig contains Orchestrion configuration. @@ -535,6 +539,14 @@ func newConfig(opts ...StartOption) *config { globalTagsOrigin := c.globalTags.cfgOrigin c.initGlobalTags(c.globalTags.get(), globalTagsOrigin) + // Check if CI Visibility mode is enabled + if internal.BoolEnv(constants.CIVisibilityEnabledEnvironmentVariable, false) { + c.ciVisibilityEnabled = true // Enable CI Visibility mode + c.httpClientTimeout = time.Second * 45 // Increase timeout up to 45 seconds (same as other tracers in CIVis mode) + c.logStartup = false // If we are in CI Visibility mode we don't want to log the startup to stdout to avoid polluting the output + c.transport = newCiVisibilityTransport(c) // Replace the default transport with the CI Visibility transport + } + return c } diff --git a/ddtrace/tracer/tracer.go b/ddtrace/tracer/tracer.go index 3f4a8f0f5f..35ee5e5243 100644 --- a/ddtrace/tracer/tracer.go +++ b/ddtrace/tracer/tracer.go @@ -235,7 +235,9 @@ func newUnstartedTracer(opts ...StartOption) *tracer { log.Warn("Runtime and health metrics disabled: %v", err) } var writer traceWriter - if c.logToStdout { + if c.ciVisibilityEnabled { + writer = newCiVisibilityTraceWriter(c) + } else if c.logToStdout { writer = newLogTraceWriter(c, statsd) } else { writer = newAgentTraceWriter(c, sampler, statsd) diff --git a/internal/civisibility/integrations/civisibility.go b/internal/civisibility/integrations/civisibility.go new file mode 100644 index 0000000000..aaa92c04bb --- /dev/null +++ b/internal/civisibility/integrations/civisibility.go @@ -0,0 +1,118 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "os" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +// ciVisibilityCloseAction defines an action to be executed when CI visibility is closing. +type ciVisibilityCloseAction func() + +var ( + // ciVisibilityInitializationOnce ensures we initialize the CI visibility tracer only once. + ciVisibilityInitializationOnce sync.Once + + // closeActions holds CI visibility close actions. + closeActions []ciVisibilityCloseAction + + // closeActionsMutex synchronizes access to closeActions. + closeActionsMutex sync.Mutex + + // mTracer contains the mock tracer instance for testing purposes + mTracer mocktracer.Tracer +) + +// EnsureCiVisibilityInitialization initializes the CI visibility tracer if it hasn't been initialized already. +func EnsureCiVisibilityInitialization() { + internalCiVisibilityInitialization(func(opts []tracer.StartOption) { + // Initialize the tracer. + tracer.Start(opts...) + }) +} + +// InitializeCIVisibilityMock initialize the mocktracer for CI Visibility usage +func InitializeCIVisibilityMock() mocktracer.Tracer { + internalCiVisibilityInitialization(func([]tracer.StartOption) { + // Initialize the mocktracer + mTracer = mocktracer.Start() + }) + return mTracer +} + +func internalCiVisibilityInitialization(tracerInitializer func([]tracer.StartOption)) { + ciVisibilityInitializationOnce.Do(func() { + // Since calling this method indicates we are in CI Visibility mode, set the environment variable. + _ = os.Setenv(constants.CIVisibilityEnabledEnvironmentVariable, "1") + + // Avoid sampling rate warning (in CI Visibility mode we send all data) + _ = os.Setenv("DD_TRACE_SAMPLE_RATE", "1") + + // Preload the CodeOwner file + _ = utils.GetCodeOwners() + + // Preload all CI, Git, and CodeOwners tags. + ciTags := utils.GetCITags() + + // Check if DD_SERVICE has been set; otherwise default to the repo name (from the spec). + var opts []tracer.StartOption + if v := os.Getenv("DD_SERVICE"); v == "" { + if repoURL, ok := ciTags[constants.GitRepositoryURL]; ok { + // regex to sanitize the repository url to be used as a service name + repoRegex := regexp.MustCompile(`(?m)/([a-zA-Z0-9\\\-_.]*)$`) + matches := repoRegex.FindStringSubmatch(repoURL) + if len(matches) > 1 { + repoURL = strings.TrimSuffix(matches[1], ".git") + } + opts = append(opts, tracer.WithService(repoURL)) + } + } + + // Initialize the tracer + tracerInitializer(opts) + + // Handle SIGINT and SIGTERM signals to ensure we close all open spans and flush the tracer before exiting + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-signals + ExitCiVisibility() + os.Exit(1) + }() + }) +} + +// PushCiVisibilityCloseAction adds a close action to be executed when CI visibility exits. +func PushCiVisibilityCloseAction(action ciVisibilityCloseAction) { + closeActionsMutex.Lock() + defer closeActionsMutex.Unlock() + closeActions = append([]ciVisibilityCloseAction{action}, closeActions...) +} + +// ExitCiVisibility executes all registered close actions and stops the tracer. +func ExitCiVisibility() { + closeActionsMutex.Lock() + defer closeActionsMutex.Unlock() + defer func() { + closeActions = []ciVisibilityCloseAction{} + + tracer.Flush() + tracer.Stop() + }() + for _, v := range closeActions { + v() + } +} diff --git a/internal/civisibility/integrations/gotesting/reflections.go b/internal/civisibility/integrations/gotesting/reflections.go new file mode 100644 index 0000000000..6aaac3ed4c --- /dev/null +++ b/internal/civisibility/integrations/gotesting/reflections.go @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "errors" + "reflect" + "sync" + "testing" + "unsafe" +) + +// getFieldPointerFrom gets an unsafe.Pointer (gc-safe type of pointer) to a struct field +// useful to get or set values to private field +func getFieldPointerFrom(value any, fieldName string) (unsafe.Pointer, error) { + indirectValue := reflect.Indirect(reflect.ValueOf(value)) + member := indirectValue.FieldByName(fieldName) + if member.IsValid() { + return unsafe.Pointer(member.UnsafeAddr()), nil + } + + return unsafe.Pointer(nil), errors.New("member is invalid") +} + +// TESTING + +// getInternalTestArray gets the pointer to the testing.InternalTest array inside a +// testing.M instance containing all the "root" tests +func getInternalTestArray(m *testing.M) *[]testing.InternalTest { + if ptr, err := getFieldPointerFrom(m, "tests"); err == nil { + return (*[]testing.InternalTest)(ptr) + } + return nil +} + +// BENCHMARKS + +// get the pointer to the internal benchmark array +// getInternalBenchmarkArray gets the pointer to the testing.InternalBenchmark array inside +// a testing.M instance containing all the "root" benchmarks +func getInternalBenchmarkArray(m *testing.M) *[]testing.InternalBenchmark { + if ptr, err := getFieldPointerFrom(m, "benchmarks"); err == nil { + return (*[]testing.InternalBenchmark)(ptr) + } + return nil +} + +// commonPrivateFields is collection of required private fields from testing.common +type commonPrivateFields struct { + mu *sync.RWMutex + level *int + name *string // Name of test or benchmark. +} + +// AddLevel increase or decrease the testing.common.level field value, used by +// testing.B to create the name of the benchmark test +func (c *commonPrivateFields) AddLevel(delta int) int { + c.mu.Lock() + defer c.mu.Unlock() + *c.level = *c.level + delta + return *c.level +} + +// benchmarkPrivateFields is a collection of required private fields from testing.B +// also contains a pointer to the original testing.B for easy access +type benchmarkPrivateFields struct { + commonPrivateFields + B *testing.B + benchFunc *func(b *testing.B) + result *testing.BenchmarkResult +} + +// getBenchmarkPrivateFields is a method to retrieve all required privates field from +// testing.B, returning a benchmarkPrivateFields instance +func getBenchmarkPrivateFields(b *testing.B) *benchmarkPrivateFields { + benchFields := &benchmarkPrivateFields{ + B: b, + } + + // common + if ptr, err := getFieldPointerFrom(b, "mu"); err == nil { + benchFields.mu = (*sync.RWMutex)(ptr) + } + if ptr, err := getFieldPointerFrom(b, "level"); err == nil { + benchFields.level = (*int)(ptr) + } + if ptr, err := getFieldPointerFrom(b, "name"); err == nil { + benchFields.name = (*string)(ptr) + } + + // benchmark + if ptr, err := getFieldPointerFrom(b, "benchFunc"); err == nil { + benchFields.benchFunc = (*func(b *testing.B))(ptr) + } + if ptr, err := getFieldPointerFrom(b, "result"); err == nil { + benchFields.result = (*testing.BenchmarkResult)(ptr) + } + + return benchFields +} diff --git a/internal/civisibility/integrations/gotesting/reflections_test.go b/internal/civisibility/integrations/gotesting/reflections_test.go new file mode 100644 index 0000000000..453cd7a0e9 --- /dev/null +++ b/internal/civisibility/integrations/gotesting/reflections_test.go @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestGetFieldPointerFrom tests the getFieldPointerFrom function. +func TestGetFieldPointerFrom(t *testing.T) { + // Create a mock struct with a private field + mockStruct := struct { + privateField string + }{ + privateField: "testValue", + } + + // Attempt to get a pointer to the private field + ptr, err := getFieldPointerFrom(&mockStruct, "privateField") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if ptr == nil { + t.Fatal("Expected a valid pointer, got nil") + } + + // Dereference the pointer to get the actual value + actualValue := (*string)(ptr) + if *actualValue != mockStruct.privateField { + t.Fatalf("Expected 'testValue', got %s", *actualValue) + } + + // Modify the value through the pointer + *actualValue = "modified value" + if *actualValue != mockStruct.privateField { + t.Fatalf("Expected 'modified value', got %s", mockStruct.privateField) + } + + // Attempt to get a pointer to a non-existent field + _, err = getFieldPointerFrom(&mockStruct, "nonExistentField") + if err == nil { + t.Fatal("Expected an error for non-existent field, got nil") + } +} + +// TestGetInternalTestArray tests the getInternalTestArray function. +func TestGetInternalTestArray(t *testing.T) { + assert := assert.New(t) + + // Get the internal test array from the mock testing.M + tests := getInternalTestArray(currentM) + assert.NotNil(tests) + + // Check that the test array contains the expected test + var testNames []string + for _, v := range *tests { + testNames = append(testNames, v.Name) + assert.NotNil(v.F) + } + + assert.Contains(testNames, "TestGetFieldPointerFrom") + assert.Contains(testNames, "TestGetInternalTestArray") + assert.Contains(testNames, "TestGetInternalBenchmarkArray") + assert.Contains(testNames, "TestCommonPrivateFields_AddLevel") + assert.Contains(testNames, "TestGetBenchmarkPrivateFields") +} + +// TestGetInternalBenchmarkArray tests the getInternalBenchmarkArray function. +func TestGetInternalBenchmarkArray(t *testing.T) { + assert := assert.New(t) + + // Get the internal benchmark array from the mock testing.M + benchmarks := getInternalBenchmarkArray(currentM) + assert.NotNil(benchmarks) + + // Check that the benchmark array contains the expected benchmark + var testNames []string + for _, v := range *benchmarks { + testNames = append(testNames, v.Name) + assert.NotNil(v.F) + } + + assert.Contains(testNames, "BenchmarkDummy") +} + +// TestCommonPrivateFields_AddLevel tests the AddLevel method of commonPrivateFields. +func TestCommonPrivateFields_AddLevel(t *testing.T) { + // Create a commonPrivateFields struct with a mutex and a level + level := 1 + commonFields := &commonPrivateFields{ + mu: &sync.RWMutex{}, + level: &level, + } + + // Add a level and check the new level + newLevel := commonFields.AddLevel(1) + if newLevel != 2 || newLevel != *commonFields.level { + t.Fatalf("Expected level to be 2, got %d", newLevel) + } + + // Subtract a level and check the new level + newLevel = commonFields.AddLevel(-1) + if newLevel != 1 || newLevel != *commonFields.level { + t.Fatalf("Expected level to be 1, got %d", newLevel) + } +} + +// TestGetBenchmarkPrivateFields tests the getBenchmarkPrivateFields function. +func TestGetBenchmarkPrivateFields(t *testing.T) { + // Create a new testing.B instance + b := &testing.B{} + + // Get the private fields of the benchmark + benchFields := getBenchmarkPrivateFields(b) + if benchFields == nil { + t.Fatal("Expected a valid benchmarkPrivateFields, got nil") + } + + // Set values to the private fields + *benchFields.name = "BenchmarkTest" + *benchFields.level = 1 + *benchFields.benchFunc = func(b *testing.B) {} + *benchFields.result = testing.BenchmarkResult{} + + // Check that the private fields have the expected values + if benchFields.level == nil || *benchFields.level != 1 { + t.Fatalf("Expected level to be 1, got %v", *benchFields.level) + } + + if benchFields.name == nil || *benchFields.name != b.Name() { + t.Fatalf("Expected name to be 'BenchmarkTest', got %v", *benchFields.name) + } + + if benchFields.benchFunc == nil { + t.Fatal("Expected benchFunc to be set, got nil") + } + + if benchFields.result == nil { + t.Fatal("Expected result to be set, got nil") + } +} + +func BenchmarkDummy(*testing.B) {} diff --git a/internal/civisibility/integrations/gotesting/testing.go b/internal/civisibility/integrations/gotesting/testing.go new file mode 100644 index 0000000000..f1c35c726a --- /dev/null +++ b/internal/civisibility/integrations/gotesting/testing.go @@ -0,0 +1,353 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "fmt" + "os" + "reflect" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +const ( + // testFramework represents the name of the testing framework. + testFramework = "golang.org/pkg/testing" +) + +var ( + // session represents the CI visibility test session. + session integrations.DdTestSession + + // testInfos holds information about the instrumented tests. + testInfos []*testingTInfo + + // benchmarkInfos holds information about the instrumented benchmarks. + benchmarkInfos []*testingBInfo + + // modulesCounters keeps track of the number of tests per module. + modulesCounters = map[string]*int32{} + + // suitesCounters keeps track of the number of tests per suite. + suitesCounters = map[string]*int32{} +) + +type ( + // commonInfo holds common information about tests and benchmarks. + commonInfo struct { + moduleName string + suiteName string + testName string + } + + // testingTInfo holds information specific to tests. + testingTInfo struct { + commonInfo + originalFunc func(*testing.T) + } + + // testingBInfo holds information specific to benchmarks. + testingBInfo struct { + commonInfo + originalFunc func(b *testing.B) + } + + // M is a wrapper around testing.M to provide instrumentation. + M testing.M +) + +// Run initializes CI Visibility, instruments tests and benchmarks, and runs them. +func (ddm *M) Run() int { + integrations.EnsureCiVisibilityInitialization() + defer integrations.ExitCiVisibility() + + // Create a new test session for CI visibility. + session = integrations.CreateTestSession() + + m := (*testing.M)(ddm) + + // Instrument the internal tests for CI visibility. + ddm.instrumentInternalTests(getInternalTestArray(m)) + + // Instrument the internal benchmarks for CI visibility. + for _, v := range os.Args { + // check if benchmarking is enabled to instrument + if strings.Contains(v, "-bench") || strings.Contains(v, "test.bench") { + ddm.instrumentInternalBenchmarks(getInternalBenchmarkArray(m)) + break + } + } + + // Run the tests and benchmarks. + var exitCode = m.Run() + + // Close the session and return the exit code. + session.Close(exitCode) + return exitCode +} + +// instrumentInternalTests instruments the internal tests for CI visibility. +func (ddm *M) instrumentInternalTests(internalTests *[]testing.InternalTest) { + if internalTests != nil { + // Extract info from internal tests + testInfos = make([]*testingTInfo, len(*internalTests)) + for idx, test := range *internalTests { + moduleName, suiteName := utils.GetModuleAndSuiteName(reflect.Indirect(reflect.ValueOf(test.F)).Pointer()) + testInfo := &testingTInfo{ + originalFunc: test.F, + commonInfo: commonInfo{ + moduleName: moduleName, + suiteName: suiteName, + testName: test.Name, + }, + } + + // Initialize module and suite counters if not already present. + if _, ok := modulesCounters[moduleName]; !ok { + var v int32 + modulesCounters[moduleName] = &v + } + // Increment the test count in the module. + atomic.AddInt32(modulesCounters[moduleName], 1) + + if _, ok := suitesCounters[suiteName]; !ok { + var v int32 + suitesCounters[suiteName] = &v + } + // Increment the test count in the suite. + atomic.AddInt32(suitesCounters[suiteName], 1) + + testInfos[idx] = testInfo + } + + // Create new instrumented internal tests + newTestArray := make([]testing.InternalTest, len(*internalTests)) + for idx, testInfo := range testInfos { + newTestArray[idx] = testing.InternalTest{ + Name: testInfo.testName, + F: ddm.executeInternalTest(testInfo), + } + } + *internalTests = newTestArray + } +} + +// executeInternalTest wraps the original test function to include CI visibility instrumentation. +func (ddm *M) executeInternalTest(testInfo *testingTInfo) func(*testing.T) { + originalFunc := runtime.FuncForPC(reflect.Indirect(reflect.ValueOf(testInfo.originalFunc)).Pointer()) + return func(t *testing.T) { + // Create or retrieve the module, suite, and test for CI visibility. + module := session.GetOrCreateModuleWithFramework(testInfo.moduleName, testFramework, runtime.Version()) + suite := module.GetOrCreateSuite(testInfo.suiteName) + test := suite.CreateTest(testInfo.testName) + test.SetTestFunc(originalFunc) + setCiVisibilityTest(t, test) + defer func() { + if r := recover(); r != nil { + // Handle panic and set error information. + test.SetErrorInfo("panic", fmt.Sprint(r), utils.GetStacktrace(1)) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.Close(integrations.ResultStatusFail) + checkModuleAndSuite(module, suite) + integrations.ExitCiVisibility() + panic(r) + } else { + // Normal finalization: determine the test result based on its state. + if t.Failed() { + test.SetTag(ext.Error, true) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.Close(integrations.ResultStatusFail) + } else if t.Skipped() { + test.Close(integrations.ResultStatusSkip) + } else { + test.Close(integrations.ResultStatusPass) + } + + checkModuleAndSuite(module, suite) + } + }() + + // Execute the original test function. + testInfo.originalFunc(t) + } +} + +// instrumentInternalBenchmarks instruments the internal benchmarks for CI visibility. +func (ddm *M) instrumentInternalBenchmarks(internalBenchmarks *[]testing.InternalBenchmark) { + if internalBenchmarks != nil { + // Extract info from internal benchmarks + benchmarkInfos = make([]*testingBInfo, len(*internalBenchmarks)) + for idx, benchmark := range *internalBenchmarks { + moduleName, suiteName := utils.GetModuleAndSuiteName(reflect.Indirect(reflect.ValueOf(benchmark.F)).Pointer()) + benchmarkInfo := &testingBInfo{ + originalFunc: benchmark.F, + commonInfo: commonInfo{ + moduleName: moduleName, + suiteName: suiteName, + testName: benchmark.Name, + }, + } + + // Initialize module and suite counters if not already present. + if _, ok := modulesCounters[moduleName]; !ok { + var v int32 + modulesCounters[moduleName] = &v + } + // Increment the test count in the module. + atomic.AddInt32(modulesCounters[moduleName], 1) + + if _, ok := suitesCounters[suiteName]; !ok { + var v int32 + suitesCounters[suiteName] = &v + } + // Increment the test count in the suite. + atomic.AddInt32(suitesCounters[suiteName], 1) + + benchmarkInfos[idx] = benchmarkInfo + } + + // Create a new instrumented internal benchmarks + newBenchmarkArray := make([]testing.InternalBenchmark, len(*internalBenchmarks)) + for idx, benchmarkInfo := range benchmarkInfos { + newBenchmarkArray[idx] = testing.InternalBenchmark{ + Name: benchmarkInfo.testName, + F: ddm.executeInternalBenchmark(benchmarkInfo), + } + } + + *internalBenchmarks = newBenchmarkArray + } +} + +// executeInternalBenchmark wraps the original benchmark function to include CI visibility instrumentation. +func (ddm *M) executeInternalBenchmark(benchmarkInfo *testingBInfo) func(*testing.B) { + return func(b *testing.B) { + + // decrement level + getBenchmarkPrivateFields(b).AddLevel(-1) + + startTime := time.Now() + originalFunc := runtime.FuncForPC(reflect.Indirect(reflect.ValueOf(benchmarkInfo.originalFunc)).Pointer()) + module := session.GetOrCreateModuleWithFrameworkAndStartTime(benchmarkInfo.moduleName, testFramework, runtime.Version(), startTime) + suite := module.GetOrCreateSuiteWithStartTime(benchmarkInfo.suiteName, startTime) + test := suite.CreateTestWithStartTime(benchmarkInfo.testName, startTime) + test.SetTestFunc(originalFunc) + + // Run the original benchmark function. + var iPfOfB *benchmarkPrivateFields + var recoverFunc *func(r any) + b.Run(b.Name(), func(b *testing.B) { + // Stop the timer to perform initialization and replacements. + b.StopTimer() + + defer func() { + if r := recover(); r != nil { + // Handle panic if it occurs during benchmark execution. + if recoverFunc != nil { + fn := *recoverFunc + fn(r) + } + panic(r) + } + }() + + // Enable allocation reporting. + b.ReportAllocs() + // Retrieve the private fields of the inner testing.B. + iPfOfB = getBenchmarkPrivateFields(b) + // Replace the benchmark function with the original one (this must be executed only once - the first iteration[b.run1]). + *iPfOfB.benchFunc = benchmarkInfo.originalFunc + // Set the CI visibility benchmark. + setCiVisibilityBenchmark(b, test) + + // Restart the timer and execute the original benchmark function. + b.ResetTimer() + b.StartTimer() + benchmarkInfo.originalFunc(b) + }) + + endTime := time.Now() + results := iPfOfB.result + + // Set benchmark data for CI visibility. + test.SetBenchmarkData("duration", map[string]any{ + "run": results.N, + "mean": results.NsPerOp(), + }) + test.SetBenchmarkData("memory_total_operations", map[string]any{ + "run": results.N, + "mean": results.AllocsPerOp(), + "statistics.max": results.MemAllocs, + }) + test.SetBenchmarkData("mean_heap_allocations", map[string]any{ + "run": results.N, + "mean": results.AllocedBytesPerOp(), + }) + test.SetBenchmarkData("total_heap_allocations", map[string]any{ + "run": results.N, + "mean": iPfOfB.result.MemBytes, + }) + if len(results.Extra) > 0 { + mapConverted := map[string]any{} + for k, v := range results.Extra { + mapConverted[k] = v + } + test.SetBenchmarkData("extra", mapConverted) + } + + // Define a function to handle panic during benchmark finalization. + panicFunc := func(r any) { + test.SetErrorInfo("panic", fmt.Sprint(r), utils.GetStacktrace(1)) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.Close(integrations.ResultStatusFail) + checkModuleAndSuite(module, suite) + integrations.ExitCiVisibility() + } + recoverFunc = &panicFunc + + // Normal finalization: determine the benchmark result based on its state. + if iPfOfB.B.Failed() { + test.SetTag(ext.Error, true) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.CloseWithFinishTime(integrations.ResultStatusFail, endTime) + } else if iPfOfB.B.Skipped() { + test.CloseWithFinishTime(integrations.ResultStatusSkip, endTime) + } else { + test.CloseWithFinishTime(integrations.ResultStatusPass, endTime) + } + + checkModuleAndSuite(module, suite) + } +} + +// RunM runs the tests and benchmarks using CI visibility. +func RunM(m *testing.M) int { + return (*M)(m).Run() +} + +// checkModuleAndSuite checks and closes the modules and suites if all tests are executed. +func checkModuleAndSuite(module integrations.DdTestModule, suite integrations.DdTestSuite) { + // If all tests in a suite has been executed we can close the suite + if atomic.AddInt32(suitesCounters[suite.Name()], -1) <= 0 { + suite.Close() + } + + // If all tests in a module has been executed we can close the module + if atomic.AddInt32(modulesCounters[module.Name()], -1) <= 0 { + module.Close() + } +} diff --git a/internal/civisibility/integrations/gotesting/testingB.go b/internal/civisibility/integrations/gotesting/testingB.go new file mode 100644 index 0000000000..0ebd7c8159 --- /dev/null +++ b/internal/civisibility/integrations/gotesting/testingB.go @@ -0,0 +1,336 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "context" + "fmt" + "reflect" + "regexp" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +var ( + // ciVisibilityBenchmarks holds a map of *testing.B to civisibility.DdTest for tracking benchmarks. + ciVisibilityBenchmarks = map[*testing.B]integrations.DdTest{} + + // ciVisibilityBenchmarksMutex is a read-write mutex for synchronizing access to ciVisibilityBenchmarks. + ciVisibilityBenchmarksMutex sync.RWMutex + + // subBenchmarkAutoName is a placeholder name for CI Visibility sub-benchmarks. + subBenchmarkAutoName = "[DD:TestVisibility]" + + // subBenchmarkAutoNameRegex is a regex pattern to match the sub-benchmark auto name. + subBenchmarkAutoNameRegex = regexp.MustCompile(`(?si)\/\[DD:TestVisibility\].*`) +) + +// B is a type alias for testing.B to provide additional methods for CI visibility. +type B testing.B + +// GetBenchmark is a helper to return *gotesting.B from *testing.B. +// Internally, it is just a (*gotesting.B)(b) cast. +func GetBenchmark(t *testing.B) *B { return (*B)(t) } + +// Run benchmarks f as a subbenchmark with the given name. It reports +// whether there were any failures. +// +// A subbenchmark is like any other benchmark. A benchmark that calls Run at +// least once will not be measured itself and will be called once with N=1. +func (ddb *B) Run(name string, f func(*testing.B)) bool { + // Reflect the function to obtain its pointer. + fReflect := reflect.Indirect(reflect.ValueOf(f)) + moduleName, suiteName := utils.GetModuleAndSuiteName(fReflect.Pointer()) + originalFunc := runtime.FuncForPC(fReflect.Pointer()) + + // Increment the test count in the module. + atomic.AddInt32(modulesCounters[moduleName], 1) + + // Increment the test count in the suite. + atomic.AddInt32(suitesCounters[suiteName], 1) + + pb := (*testing.B)(ddb) + return pb.Run(subBenchmarkAutoName, func(b *testing.B) { + // The sub-benchmark implementation relies on creating a dummy sub benchmark (called [DD:TestVisibility]) with + // a Run over the original sub benchmark function to get the child results without interfering measurements + // By doing this the name of the sub-benchmark are changed + // from: + // benchmark/child + // to: + // benchmark/[DD:TestVisibility]/child + // We use regex and decrement the depth level of the benchmark to restore the original name + + // Decrement level. + bpf := getBenchmarkPrivateFields(b) + bpf.AddLevel(-1) + + startTime := time.Now() + module := session.GetOrCreateModuleWithFrameworkAndStartTime(moduleName, testFramework, runtime.Version(), startTime) + suite := module.GetOrCreateSuiteWithStartTime(suiteName, startTime) + test := suite.CreateTestWithStartTime(fmt.Sprintf("%s/%s", pb.Name(), name), startTime) + test.SetTestFunc(originalFunc) + + // Restore the original name without the sub-benchmark auto name. + *bpf.name = subBenchmarkAutoNameRegex.ReplaceAllString(*bpf.name, "") + + // Run original benchmark. + var iPfOfB *benchmarkPrivateFields + var recoverFunc *func(r any) + b.Run(name, func(b *testing.B) { + // Stop the timer to do the initialization and replacements. + b.StopTimer() + + defer func() { + if r := recover(); r != nil { + if recoverFunc != nil { + fn := *recoverFunc + fn(r) + } + panic(r) + } + }() + + // First time we get the private fields of the inner testing.B. + iPfOfB = getBenchmarkPrivateFields(b) + // Replace this function with the original one (executed only once - the first iteration[b.run1]). + *iPfOfB.benchFunc = f + // Set b to the CI visibility test. + setCiVisibilityBenchmark(b, test) + + // Enable the timer again. + b.ResetTimer() + b.StartTimer() + + // Execute original func + f(b) + }) + + endTime := time.Now() + results := iPfOfB.result + + // Set benchmark data for CI visibility. + test.SetBenchmarkData("duration", map[string]any{ + "run": results.N, + "mean": results.NsPerOp(), + }) + test.SetBenchmarkData("memory_total_operations", map[string]any{ + "run": results.N, + "mean": results.AllocsPerOp(), + "statistics.max": results.MemAllocs, + }) + test.SetBenchmarkData("mean_heap_allocations", map[string]any{ + "run": results.N, + "mean": results.AllocedBytesPerOp(), + }) + test.SetBenchmarkData("total_heap_allocations", map[string]any{ + "run": results.N, + "mean": iPfOfB.result.MemBytes, + }) + if len(results.Extra) > 0 { + mapConverted := map[string]any{} + for k, v := range results.Extra { + mapConverted[k] = v + } + test.SetBenchmarkData("extra", mapConverted) + } + + // Define a function to handle panic during benchmark finalization. + panicFunc := func(r any) { + test.SetErrorInfo("panic", fmt.Sprint(r), utils.GetStacktrace(1)) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.Close(integrations.ResultStatusFail) + checkModuleAndSuite(module, suite) + integrations.ExitCiVisibility() + } + recoverFunc = &panicFunc + + // Normal finalization: determine the benchmark result based on its state. + if iPfOfB.B.Failed() { + test.SetTag(ext.Error, true) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.CloseWithFinishTime(integrations.ResultStatusFail, endTime) + } else if iPfOfB.B.Skipped() { + test.CloseWithFinishTime(integrations.ResultStatusSkip, endTime) + } else { + test.CloseWithFinishTime(integrations.ResultStatusPass, endTime) + } + + checkModuleAndSuite(module, suite) + }) +} + +// Context returns the CI Visibility context of the Test span. +// This may be used to create test's children spans useful for +// integration tests. +func (ddb *B) Context() context.Context { + b := (*testing.B)(ddb) + ciTest := getCiVisibilityBenchmark(b) + if ciTest != nil { + return ciTest.Context() + } + + return context.Background() +} + +// Fail marks the function as having failed but continues execution. +func (ddb *B) Fail() { ddb.getBWithError("Fail", "failed test").Fail() } + +// FailNow marks the function as having failed and stops its execution +// by calling runtime.Goexit (which then runs all deferred calls in the +// current goroutine). Execution will continue at the next test or benchmark. +// FailNow must be called from the goroutine running the test or benchmark function, +// not from other goroutines created during the test. Calling FailNow does not stop +// those other goroutines. +func (ddb *B) FailNow() { + b := ddb.getBWithError("FailNow", "failed test") + integrations.ExitCiVisibility() + b.FailNow() +} + +// Error is equivalent to Log followed by Fail. +func (ddb *B) Error(args ...any) { ddb.getBWithError("Error", fmt.Sprint(args...)).Error(args...) } + +// Errorf is equivalent to Logf followed by Fail. +func (ddb *B) Errorf(format string, args ...any) { + ddb.getBWithError("Errorf", fmt.Sprintf(format, args...)).Errorf(format, args...) +} + +// Fatal is equivalent to Log followed by FailNow. +func (ddb *B) Fatal(args ...any) { ddb.getBWithError("Fatal", fmt.Sprint(args...)).Fatal(args...) } + +// Fatalf is equivalent to Logf followed by FailNow. +func (ddb *B) Fatalf(format string, args ...any) { + ddb.getBWithError("Fatalf", fmt.Sprintf(format, args...)).Fatalf(format, args...) +} + +// Skip is equivalent to Log followed by SkipNow. +func (ddb *B) Skip(args ...any) { ddb.getBWithSkip(fmt.Sprint(args...)).Skip(args...) } + +// Skipf is equivalent to Logf followed by SkipNow. +func (ddb *B) Skipf(format string, args ...any) { + ddb.getBWithSkip(fmt.Sprintf(format, args...)).Skipf(format, args...) +} + +// SkipNow marks the test as having been skipped and stops its execution +// by calling runtime.Goexit. If a test fails (see Error, Errorf, Fail) and is then skipped, +// it is still considered to have failed. Execution will continue at the next test or benchmark. +// SkipNow must be called from the goroutine running the test, not from other goroutines created +// during the test. Calling SkipNow does not stop those other goroutines. +func (ddb *B) SkipNow() { + b := (*testing.B)(ddb) + ciTest := getCiVisibilityBenchmark(b) + if ciTest != nil { + ciTest.Close(integrations.ResultStatusSkip) + } + + b.SkipNow() +} + +// StartTimer starts timing a test. This function is called automatically +// before a benchmark starts, but it can also be used to resume timing after +// a call to StopTimer. +func (ddb *B) StartTimer() { (*testing.B)(ddb).StartTimer() } + +// StopTimer stops timing a test. This can be used to pause the timer +// while performing complex initialization that you don't want to measure. +func (ddb *B) StopTimer() { (*testing.B)(ddb).StopTimer() } + +// ReportAllocs enables malloc statistics for this benchmark. +// It is equivalent to setting -test.benchmem, but it only affects the +// benchmark function that calls ReportAllocs. +func (ddb *B) ReportAllocs() { (*testing.B)(ddb).ReportAllocs() } + +// ResetTimer zeroes the elapsed benchmark time and memory allocation counters +// and deletes user-reported metrics. It does not affect whether the timer is running. +func (ddb *B) ResetTimer() { (*testing.B)(ddb).ResetTimer() } + +// Elapsed returns the measured elapsed time of the benchmark. +// The duration reported by Elapsed matches the one measured by +// StartTimer, StopTimer, and ResetTimer. +func (ddb *B) Elapsed() time.Duration { + return (*testing.B)(ddb).Elapsed() +} + +// ReportMetric adds "n unit" to the reported benchmark results. +// If the metric is per-iteration, the caller should divide by b.N, +// and by convention units should end in "/op". +// ReportMetric overrides any previously reported value for the same unit. +// ReportMetric panics if unit is the empty string or if unit contains +// any whitespace. +// If unit is a unit normally reported by the benchmark framework itself +// (such as "allocs/op"), ReportMetric will override that metric. +// Setting "ns/op" to 0 will suppress that built-in metric. +func (ddb *B) ReportMetric(n float64, unit string) { (*testing.B)(ddb).ReportMetric(n, unit) } + +// RunParallel runs a benchmark in parallel. +// It creates multiple goroutines and distributes b.N iterations among them. +// The number of goroutines defaults to GOMAXPROCS. To increase parallelism for +// non-CPU-bound benchmarks, call SetParallelism before RunParallel. +// RunParallel is usually used with the go test -cpu flag. +// +// The body function will be run in each goroutine. It should set up any +// goroutine-local state and then iterate until pb.Next returns false. +// It should not use the StartTimer, StopTimer, or ResetTimer functions, +// because they have global effect. It should also not call Run. +// +// RunParallel reports ns/op values as wall time for the benchmark as a whole, +// not the sum of wall time or CPU time over each parallel goroutine. +func (ddb *B) RunParallel(body func(*testing.PB)) { (*testing.B)(ddb).RunParallel(body) } + +// SetBytes records the number of bytes processed in a single operation. +// If this is called, the benchmark will report ns/op and MB/s. +func (ddb *B) SetBytes(n int64) { (*testing.B)(ddb).SetBytes(n) } + +// SetParallelism sets the number of goroutines used by RunParallel to p*GOMAXPROCS. +// There is usually no need to call SetParallelism for CPU-bound benchmarks. +// If p is less than 1, this call will have no effect. +func (ddb *B) SetParallelism(p int) { (*testing.B)(ddb).SetParallelism(p) } + +func (ddb *B) getBWithError(errType string, errMessage string) *testing.B { + b := (*testing.B)(ddb) + ciTest := getCiVisibilityBenchmark(b) + if ciTest != nil { + ciTest.SetErrorInfo(errType, errMessage, utils.GetStacktrace(2)) + } + return b +} + +func (ddb *B) getBWithSkip(skipReason string) *testing.B { + b := (*testing.B)(ddb) + ciTest := getCiVisibilityBenchmark(b) + if ciTest != nil { + ciTest.CloseWithFinishTimeAndSkipReason(integrations.ResultStatusSkip, time.Now(), skipReason) + } + return b +} + +// getCiVisibilityBenchmark retrieves the CI visibility benchmark associated with a given *testing.B. +func getCiVisibilityBenchmark(b *testing.B) integrations.DdTest { + ciVisibilityBenchmarksMutex.RLock() + defer ciVisibilityBenchmarksMutex.RUnlock() + + if v, ok := ciVisibilityBenchmarks[b]; ok { + return v + } + + return nil +} + +// setCiVisibilityBenchmark associates a CI visibility benchmark with a given *testing.B. +func setCiVisibilityBenchmark(b *testing.B, ciTest integrations.DdTest) { + ciVisibilityBenchmarksMutex.Lock() + defer ciVisibilityBenchmarksMutex.Unlock() + ciVisibilityBenchmarks[b] = ciTest +} diff --git a/internal/civisibility/integrations/gotesting/testingT.go b/internal/civisibility/integrations/gotesting/testingT.go new file mode 100644 index 0000000000..2ac53c3fa8 --- /dev/null +++ b/internal/civisibility/integrations/gotesting/testingT.go @@ -0,0 +1,216 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "context" + "fmt" + "reflect" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +var ( + // ciVisibilityTests holds a map of *testing.T to civisibility.DdTest for tracking tests. + ciVisibilityTests = map[*testing.T]integrations.DdTest{} + + // ciVisibilityTestsMutex is a read-write mutex for synchronizing access to ciVisibilityTests. + ciVisibilityTestsMutex sync.RWMutex +) + +// T is a type alias for testing.T to provide additional methods for CI visibility. +type T testing.T + +// GetTest is a helper to return *gotesting.T from *testing.T. +// Internally, it is just a (*gotesting.T)(t) cast. +func GetTest(t *testing.T) *T { + return (*T)(t) +} + +// Run runs f as a subtest of t called name. It runs f in a separate goroutine +// and blocks until f returns or calls t.Parallel to become a parallel test. +// Run reports whether f succeeded (or at least did not fail before calling t.Parallel). +// +// Run may be called simultaneously from multiple goroutines, but all such calls +// must return before the outer test function for t returns. +func (ddt *T) Run(name string, f func(*testing.T)) bool { + // Reflect the function to obtain its pointer. + fReflect := reflect.Indirect(reflect.ValueOf(f)) + moduleName, suiteName := utils.GetModuleAndSuiteName(fReflect.Pointer()) + originalFunc := runtime.FuncForPC(fReflect.Pointer()) + + // Increment the test count in the module. + atomic.AddInt32(modulesCounters[moduleName], 1) + + // Increment the test count in the suite. + atomic.AddInt32(suitesCounters[suiteName], 1) + + t := (*testing.T)(ddt) + return t.Run(name, func(t *testing.T) { + // Create or retrieve the module, suite, and test for CI visibility. + module := session.GetOrCreateModuleWithFramework(moduleName, testFramework, runtime.Version()) + suite := module.GetOrCreateSuite(suiteName) + test := suite.CreateTest(t.Name()) + test.SetTestFunc(originalFunc) + setCiVisibilityTest(t, test) + defer func() { + if r := recover(); r != nil { + // Handle panic and set error information. + test.SetErrorInfo("panic", fmt.Sprint(r), utils.GetStacktrace(1)) + test.Close(integrations.ResultStatusFail) + checkModuleAndSuite(module, suite) + integrations.ExitCiVisibility() + panic(r) + } else { + // Normal finalization: determine the test result based on its state. + if t.Failed() { + test.SetTag(ext.Error, true) + suite.SetTag(ext.Error, true) + module.SetTag(ext.Error, true) + test.Close(integrations.ResultStatusFail) + } else if t.Skipped() { + test.Close(integrations.ResultStatusSkip) + } else { + test.Close(integrations.ResultStatusPass) + } + checkModuleAndSuite(module, suite) + } + }() + + // Execute the original test function. + f(t) + }) +} + +// Context returns the CI Visibility context of the Test span. +// This may be used to create test's children spans useful for +// integration tests. +func (ddt *T) Context() context.Context { + t := (*testing.T)(ddt) + ciTest := getCiVisibilityTest(t) + if ciTest != nil { + return ciTest.Context() + } + + return context.Background() +} + +// Fail marks the function as having failed but continues execution. +func (ddt *T) Fail() { ddt.getTWithError("Fail", "failed test").Fail() } + +// FailNow marks the function as having failed and stops its execution +// by calling runtime.Goexit (which then runs all deferred calls in the +// current goroutine). Execution will continue at the next test or benchmark. +// FailNow must be called from the goroutine running the test or benchmark function, +// not from other goroutines created during the test. Calling FailNow does not stop +// those other goroutines. +func (ddt *T) FailNow() { + t := ddt.getTWithError("FailNow", "failed test") + integrations.ExitCiVisibility() + t.FailNow() +} + +// Error is equivalent to Log followed by Fail. +func (ddt *T) Error(args ...any) { ddt.getTWithError("Error", fmt.Sprint(args...)).Error(args...) } + +// Errorf is equivalent to Logf followed by Fail. +func (ddt *T) Errorf(format string, args ...any) { + ddt.getTWithError("Errorf", fmt.Sprintf(format, args...)).Errorf(format, args...) +} + +// Fatal is equivalent to Log followed by FailNow. +func (ddt *T) Fatal(args ...any) { ddt.getTWithError("Fatal", fmt.Sprint(args...)).Fatal(args...) } + +// Fatalf is equivalent to Logf followed by FailNow. +func (ddt *T) Fatalf(format string, args ...any) { + ddt.getTWithError("Fatalf", fmt.Sprintf(format, args...)).Fatalf(format, args...) +} + +// Skip is equivalent to Log followed by SkipNow. +func (ddt *T) Skip(args ...any) { ddt.getTWithSkip(fmt.Sprint(args...)).Skip(args...) } + +// Skipf is equivalent to Logf followed by SkipNow. +func (ddt *T) Skipf(format string, args ...any) { + ddt.getTWithSkip(fmt.Sprintf(format, args...)).Skipf(format, args...) +} + +// SkipNow marks the test as having been skipped and stops its execution +// by calling runtime.Goexit. If a test fails (see Error, Errorf, Fail) and is then skipped, +// it is still considered to have failed. Execution will continue at the next test or benchmark. +// SkipNow must be called from the goroutine running the test, not from other goroutines created +// during the test. Calling SkipNow does not stop those other goroutines. +func (ddt *T) SkipNow() { + t := (*testing.T)(ddt) + ciTest := getCiVisibilityTest(t) + if ciTest != nil { + ciTest.Close(integrations.ResultStatusSkip) + } + + t.SkipNow() +} + +// Parallel signals that this test is to be run in parallel with (and only with) +// other parallel tests. When a test is run multiple times due to use of +// -test.count or -test.cpu, multiple instances of a single test never run in +// parallel with each other. +func (ddt *T) Parallel() { (*testing.T)(ddt).Parallel() } + +// Deadline reports the time at which the test binary will have +// exceeded the timeout specified by the -timeout flag. +// The ok result is false if the -timeout flag indicates “no timeout” (0). +func (ddt *T) Deadline() (deadline time.Time, ok bool) { + return (*testing.T)(ddt).Deadline() +} + +// Setenv calls os.Setenv(key, value) and uses Cleanup to +// restore the environment variable to its original value +// after the test. Because Setenv affects the whole process, +// it cannot be used in parallel tests or tests with parallel ancestors. +func (ddt *T) Setenv(key, value string) { (*testing.T)(ddt).Setenv(key, value) } + +func (ddt *T) getTWithError(errType string, errMessage string) *testing.T { + t := (*testing.T)(ddt) + ciTest := getCiVisibilityTest(t) + if ciTest != nil { + ciTest.SetErrorInfo(errType, errMessage, utils.GetStacktrace(2)) + } + return t +} + +func (ddt *T) getTWithSkip(skipReason string) *testing.T { + t := (*testing.T)(ddt) + ciTest := getCiVisibilityTest(t) + if ciTest != nil { + ciTest.CloseWithFinishTimeAndSkipReason(integrations.ResultStatusSkip, time.Now(), skipReason) + } + return t +} + +// getCiVisibilityTest retrieves the CI visibility test associated with a given *testing.T. +func getCiVisibilityTest(t *testing.T) integrations.DdTest { + ciVisibilityTestsMutex.RLock() + defer ciVisibilityTestsMutex.RUnlock() + + if v, ok := ciVisibilityTests[t]; ok { + return v + } + + return nil +} + +// setCiVisibilityTest associates a CI visibility test with a given *testing.T. +func setCiVisibilityTest(t *testing.T, ciTest integrations.DdTest) { + ciVisibilityTestsMutex.Lock() + defer ciVisibilityTestsMutex.Unlock() + ciVisibilityTests[t] = ciTest +} diff --git a/internal/civisibility/integrations/gotesting/testing_test.go b/internal/civisibility/integrations/gotesting/testing_test.go new file mode 100644 index 0000000000..a2b7ac1f1b --- /dev/null +++ b/internal/civisibility/integrations/gotesting/testing_test.go @@ -0,0 +1,350 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package gotesting + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + + ddhttp "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations" + + "github.com/stretchr/testify/assert" +) + +var currentM *testing.M +var mTracer mocktracer.Tracer + +// TestMain is the entry point for testing and runs before any test. +func TestMain(m *testing.M) { + currentM = m + mTracer = integrations.InitializeCIVisibilityMock() + + // (*M)(m).Run() cast m to gotesting.M and just run + // or use a helper method gotesting.RunM(m) + + // os.Exit((*M)(m).Run()) + _ = RunM(m) + + finishedSpans := mTracer.FinishedSpans() + // 1 session span + // 1 module span + // 1 suite span (optional 1 from reflections_test.go) + // 6 tests spans + // 7 sub stest spans + // 2 normal spans (from integration tests) + // 1 benchmark span (optional - require the -bench option) + if len(finishedSpans) < 17 { + panic("expected at least 17 finished spans, got " + strconv.Itoa(len(finishedSpans))) + } + + sessionSpans := getSpansWithType(finishedSpans, constants.SpanTypeTestSession) + if len(sessionSpans) != 1 { + panic("expected exactly 1 session span, got " + strconv.Itoa(len(sessionSpans))) + } + + moduleSpans := getSpansWithType(finishedSpans, constants.SpanTypeTestModule) + if len(moduleSpans) != 1 { + panic("expected exactly 1 module span, got " + strconv.Itoa(len(moduleSpans))) + } + + suiteSpans := getSpansWithType(finishedSpans, constants.SpanTypeTestSuite) + if len(suiteSpans) < 1 { + panic("expected at least 1 suite span, got " + strconv.Itoa(len(suiteSpans))) + } + + testSpans := getSpansWithType(finishedSpans, constants.SpanTypeTest) + if len(testSpans) < 12 { + panic("expected at least 12 suite span, got " + strconv.Itoa(len(testSpans))) + } + + httpSpans := getSpansWithType(finishedSpans, ext.SpanTypeHTTP) + if len(httpSpans) != 2 { + panic("expected exactly 2 normal spans, got " + strconv.Itoa(len(httpSpans))) + } + + os.Exit(0) +} + +// TestMyTest01 demonstrates instrumentation of InternalTests +func TestMyTest01(t *testing.T) { + assertTest(t) +} + +// TestMyTest02 demonstrates instrumentation of subtests. +func TestMyTest02(gt *testing.T) { + assertTest(gt) + + // To instrument subTests we just need to cast + // testing.T to our gotesting.T + // using: newT := (*gotesting.T)(t) + // Then all testing.T will be available but instrumented + t := (*T)(gt) + // or + t = GetTest(gt) + + t.Run("sub01", func(oT2 *testing.T) { + t2 := (*T)(oT2) // Cast the sub-test to gotesting.T + t2.Log("From sub01") + t2.Run("sub03", func(t3 *testing.T) { + t3.Log("From sub03") + }) + }) +} + +func Test_Foo(gt *testing.T) { + assertTest(gt) + t := (*T)(gt) + var tests = []struct { + name string + input string + want string + }{ + {"yellow should return color", "yellow", "color"}, + {"banana should return fruit", "banana", "fruit"}, + {"duck should return animal", "duck", "animal"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Log(test.name) + }) + } +} + +// TestWithExternalCalls demonstrates testing with external HTTP calls. +func TestWithExternalCalls(gt *testing.T) { + assertTest(gt) + t := (*T)(gt) + + // Create a new HTTP test server + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Hello World")) + })) + defer s.Close() + + t.Run("default", func(t *testing.T) { + + // if we want to use the test span as a parent of a child span + // we can extract the SpanContext and use it in other integrations + ctx := (*T)(t).Context() + + // Wrap the default HTTP transport for tracing + rt := ddhttp.WrapRoundTripper(http.DefaultTransport) + client := &http.Client{ + Transport: rt, + } + + // Create a new HTTP request + req, err := http.NewRequest("GET", s.URL+"/hello/world", nil) + if err != nil { + t.FailNow() + } + + // Use the span context here so the http span will appear as a child of the test + req = req.WithContext(ctx) + + res, err := client.Do(req) + if err != nil { + t.FailNow() + } + _ = res.Body.Close() + }) + + t.Run("custom-name", func(t *testing.T) { + + // we can also add custom tags to the test span by retrieving the + // context and call the `ddtracer.SpanFromContext` api + ctx := (*T)(t).Context() + span, _ := ddtracer.SpanFromContext(ctx) + + // Custom namer function for the HTTP request + customNamer := func(req *http.Request) string { + value := fmt.Sprintf("%s %s", req.Method, req.URL.Path) + + // Then we can set custom tags to that test span + span.SetTag("customNamer.Value", value) + return value + } + + rt := ddhttp.WrapRoundTripper(http.DefaultTransport, ddhttp.RTWithResourceNamer(customNamer)) + client := &http.Client{ + Transport: rt, + } + + req, err := http.NewRequest("GET", s.URL+"/hello/world", nil) + if err != nil { + t.FailNow() + } + + // Use the span context here so the http span will appear as a child of the test + req = req.WithContext(ctx) + + res, err := client.Do(req) + if err != nil { + t.FailNow() + } + _ = res.Body.Close() + }) +} + +// TestSkip demonstrates skipping a test with a message. +func TestSkip(gt *testing.T) { + assertTest(gt) + + t := (*T)(gt) + + // because we use the instrumented Skip + // the message will be reported as the skip reason. + t.Skip("Nothing to do here, skipping!") +} + +// BenchmarkFirst demonstrates benchmark instrumentation with sub-benchmarks. +func BenchmarkFirst(gb *testing.B) { + + // Same happens with sub benchmarks + // we just need to cast testing.B to gotesting.B + // using: newB := (*gotesting.B)(b) + b := (*B)(gb) + // or + b = GetBenchmark(gb) + + var mapArray []map[string]string + b.Run("child01", func(b *testing.B) { + for i := 0; i < b.N; i++ { + mapArray = append(mapArray, map[string]string{}) + } + }) + + b.Run("child02", func(b *testing.B) { + for i := 0; i < b.N; i++ { + mapArray = append(mapArray, map[string]string{}) + } + }) + + b.Run("child03", func(b *testing.B) { + GetBenchmark(b).Skip("The reason...") + }) + + _ = gb.Elapsed() +} + +func assertTest(t *testing.T) { + assert := assert.New(t) + spans := mTracer.OpenSpans() + hasSession := false + hasModule := false + hasSuite := false + hasTest := false + for _, span := range spans { + spanTags := span.Tags() + + // Assert Session + if span.Tag(ext.SpanType) == constants.SpanTypeTestSession { + assert.Contains(spanTags, constants.TestSessionIDTag) + assertCommon(assert, span) + hasSession = true + } + + // Assert Module + if span.Tag(ext.SpanType) == constants.SpanTypeTestModule { + assert.Subset(spanTags, map[string]interface{}{ + constants.TestModule: "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations/gotesting", + constants.TestFramework: "golang.org/pkg/testing", + }) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + assert.Contains(spanTags, constants.TestFrameworkVersion) + assertCommon(assert, span) + hasModule = true + } + + // Assert Suite + if span.Tag(ext.SpanType) == constants.SpanTypeTestSuite { + assert.Subset(spanTags, map[string]interface{}{ + constants.TestModule: "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations/gotesting", + constants.TestFramework: "golang.org/pkg/testing", + }) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + assert.Contains(spanTags, constants.TestSuiteIDTag) + assert.Contains(spanTags, constants.TestFrameworkVersion) + assert.Contains(spanTags, constants.TestSuite) + assertCommon(assert, span) + hasSuite = true + } + + // Assert Test + if span.Tag(ext.SpanType) == constants.SpanTypeTest { + assert.Subset(spanTags, map[string]interface{}{ + constants.TestModule: "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/integrations/gotesting", + constants.TestFramework: "golang.org/pkg/testing", + constants.TestSuite: "testing_test.go", + constants.TestName: t.Name(), + constants.TestType: constants.TestTypeTest, + }) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + assert.Contains(spanTags, constants.TestSuiteIDTag) + assert.Contains(spanTags, constants.TestFrameworkVersion) + assert.Contains(spanTags, constants.TestCodeOwners) + assert.Contains(spanTags, constants.TestSourceFile) + assert.Contains(spanTags, constants.TestSourceStartLine) + assertCommon(assert, span) + hasTest = true + } + } + + assert.True(hasSession) + assert.True(hasModule) + assert.True(hasSuite) + assert.True(hasTest) +} + +func assertCommon(assert *assert.Assertions, span mocktracer.Span) { + spanTags := span.Tags() + + assert.Subset(spanTags, map[string]interface{}{ + constants.Origin: constants.CIAppTestOrigin, + constants.TestType: constants.TestTypeTest, + }) + + assert.Contains(spanTags, ext.ResourceName) + assert.Contains(spanTags, constants.TestCommand) + assert.Contains(spanTags, constants.TestCommandWorkingDirectory) + assert.Contains(spanTags, constants.OSPlatform) + assert.Contains(spanTags, constants.OSArchitecture) + assert.Contains(spanTags, constants.OSVersion) + assert.Contains(spanTags, constants.RuntimeVersion) + assert.Contains(spanTags, constants.RuntimeName) + assert.Contains(spanTags, constants.GitRepositoryURL) + assert.Contains(spanTags, constants.GitCommitSHA) + assert.Contains(spanTags, constants.GitCommitMessage) + assert.Contains(spanTags, constants.GitCommitAuthorEmail) + assert.Contains(spanTags, constants.GitCommitAuthorDate) + assert.Contains(spanTags, constants.GitCommitCommitterEmail) + assert.Contains(spanTags, constants.GitCommitCommitterDate) + assert.Contains(spanTags, constants.GitCommitCommitterName) + assert.Contains(spanTags, constants.CIWorkspacePath) +} + +func getSpansWithType(spans []mocktracer.Span, spanType string) []mocktracer.Span { + var result []mocktracer.Span + for _, span := range spans { + if span.Tag(ext.SpanType) == spanType { + result = append(result, span) + } + } + + return result +} diff --git a/internal/civisibility/integrations/manual_api.go b/internal/civisibility/integrations/manual_api.go new file mode 100644 index 0000000000..276898ad1a --- /dev/null +++ b/internal/civisibility/integrations/manual_api.go @@ -0,0 +1,218 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "runtime" + "sync" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +// TestResultStatus represents the result status of a test. +type TestResultStatus int + +const ( + // ResultStatusPass indicates that the test has passed. + ResultStatusPass TestResultStatus = 0 + + // ResultStatusFail indicates that the test has failed. + ResultStatusFail TestResultStatus = 1 + + // ResultStatusSkip indicates that the test has been skipped. + ResultStatusSkip TestResultStatus = 2 +) + +// ddTslvEvent is an interface that provides common methods for CI visibility events. +type ddTslvEvent interface { + // Context returns the context of the event. + Context() context.Context + + // StartTime returns the start time of the event. + StartTime() time.Time + + // SetError sets an error on the event. + SetError(err error) + + // SetErrorInfo sets detailed error information on the event. + SetErrorInfo(errType string, message string, callstack string) + + // SetTag sets a tag on the event. + SetTag(key string, value interface{}) +} + +// DdTestSession represents a session for a set of tests. +type DdTestSession interface { + ddTslvEvent + + // Command returns the command used to run the session. + Command() string + + // Framework returns the testing framework used. + Framework() string + + // WorkingDirectory returns the working directory of the session. + WorkingDirectory() string + + // Close closes the test session with the given exit code. + Close(exitCode int) + + // CloseWithFinishTime closes the test session with the given exit code and finish time. + CloseWithFinishTime(exitCode int, finishTime time.Time) + + // GetOrCreateModule returns an existing module or creates a new one with the given name. + GetOrCreateModule(name string) DdTestModule + + // GetOrCreateModuleWithFramework returns an existing module or creates a new one with the given name, framework, and framework version. + GetOrCreateModuleWithFramework(name string, framework string, frameworkVersion string) DdTestModule + + // GetOrCreateModuleWithFrameworkAndStartTime returns an existing module or creates a new one with the given name, framework, framework version, and start time. + GetOrCreateModuleWithFrameworkAndStartTime(name string, framework string, frameworkVersion string, startTime time.Time) DdTestModule +} + +// DdTestModule represents a module within a test session. +type DdTestModule interface { + ddTslvEvent + + // Session returns the test session to which the module belongs. + Session() DdTestSession + + // Framework returns the testing framework used by the module. + Framework() string + + // Name returns the name of the module. + Name() string + + // Close closes the test module. + Close() + + // CloseWithFinishTime closes the test module with the given finish time. + CloseWithFinishTime(finishTime time.Time) + + // GetOrCreateSuite returns an existing suite or creates a new one with the given name. + GetOrCreateSuite(name string) DdTestSuite + + // GetOrCreateSuiteWithStartTime returns an existing suite or creates a new one with the given name and start time. + GetOrCreateSuiteWithStartTime(name string, startTime time.Time) DdTestSuite +} + +// DdTestSuite represents a suite of tests within a module. +type DdTestSuite interface { + ddTslvEvent + + // Module returns the module to which the suite belongs. + Module() DdTestModule + + // Name returns the name of the suite. + Name() string + + // Close closes the test suite. + Close() + + // CloseWithFinishTime closes the test suite with the given finish time. + CloseWithFinishTime(finishTime time.Time) + + // CreateTest creates a new test with the given name. + CreateTest(name string) DdTest + + // CreateTestWithStartTime creates a new test with the given name and start time. + CreateTestWithStartTime(name string, startTime time.Time) DdTest +} + +// DdTest represents an individual test within a suite. +type DdTest interface { + ddTslvEvent + + // Name returns the name of the test. + Name() string + + // Suite returns the suite to which the test belongs. + Suite() DdTestSuite + + // Close closes the test with the given status. + Close(status TestResultStatus) + + // CloseWithFinishTime closes the test with the given status and finish time. + CloseWithFinishTime(status TestResultStatus, finishTime time.Time) + + // CloseWithFinishTimeAndSkipReason closes the test with the given status, finish time, and skip reason. + CloseWithFinishTimeAndSkipReason(status TestResultStatus, finishTime time.Time, skipReason string) + + // SetTestFunc sets the function to be tested. (Sets the test.source tags and test.codeowners) + SetTestFunc(fn *runtime.Func) + + // SetBenchmarkData sets benchmark data for the test. + SetBenchmarkData(measureType string, data map[string]any) +} + +// common +var _ ddTslvEvent = (*ciVisibilityCommon)(nil) + +// ciVisibilityCommon is a struct that implements the ddTslvEvent interface and provides common functionality for CI visibility. +type ciVisibilityCommon struct { + startTime time.Time + + tags []tracer.StartSpanOption + span tracer.Span + ctx context.Context + mutex sync.Mutex + closed bool +} + +// Context returns the context of the event. +func (c *ciVisibilityCommon) Context() context.Context { return c.ctx } + +// StartTime returns the start time of the event. +func (c *ciVisibilityCommon) StartTime() time.Time { return c.startTime } + +// SetError sets an error on the event. +func (c *ciVisibilityCommon) SetError(err error) { + c.span.SetTag(ext.Error, err) +} + +// SetErrorInfo sets detailed error information on the event. +func (c *ciVisibilityCommon) SetErrorInfo(errType string, message string, callstack string) { + // set the span with error:1 + c.span.SetTag(ext.Error, true) + + // set the error type + if errType != "" { + c.span.SetTag(ext.ErrorType, errType) + } + + // set the error message + if message != "" { + c.span.SetTag(ext.ErrorMsg, message) + } + + // set the error stacktrace + if callstack != "" { + c.span.SetTag(ext.ErrorStack, callstack) + } +} + +// SetTag sets a tag on the event. +func (c *ciVisibilityCommon) SetTag(key string, value interface{}) { c.span.SetTag(key, value) } + +// fillCommonTags adds common tags to the span options for CI visibility. +func fillCommonTags(opts []tracer.StartSpanOption) []tracer.StartSpanOption { + opts = append(opts, []tracer.StartSpanOption{ + tracer.Tag(constants.Origin, constants.CIAppTestOrigin), + tracer.Tag(ext.ManualKeep, true), + }...) + + // Apply CI tags + for k, v := range utils.GetCITags() { + opts = append(opts, tracer.Tag(k, v)) + } + + return opts +} diff --git a/internal/civisibility/integrations/manual_api_ddtest.go b/internal/civisibility/integrations/manual_api_ddtest.go new file mode 100644 index 0000000000..cdb01f4d4b --- /dev/null +++ b/internal/civisibility/integrations/manual_api_ddtest.go @@ -0,0 +1,157 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "fmt" + "runtime" + "strings" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +// Test + +// Ensures that tslvTest implements the DdTest interface. +var _ DdTest = (*tslvTest)(nil) + +// tslvTest implements the DdTest interface and represents an individual test within a suite. +type tslvTest struct { + ciVisibilityCommon + suite *tslvTestSuite + name string +} + +// createTest initializes a new test within a given suite. +func createTest(suite *tslvTestSuite, name string, startTime time.Time) DdTest { + if suite == nil { + return nil + } + + operationName := "test" + if suite.module.framework != "" { + operationName = fmt.Sprintf("%s.%s", strings.ToLower(suite.module.framework), operationName) + } + + resourceName := fmt.Sprintf("%s.%s", suite.name, name) + + // Test tags should include suite, module, and session tags so the backend can calculate the suite, module, and session fingerprint from the test. + testTags := append(suite.tags, tracer.Tag(constants.TestName, name)) + testOpts := append(fillCommonTags([]tracer.StartSpanOption{ + tracer.ResourceName(resourceName), + tracer.SpanType(constants.SpanTypeTest), + tracer.StartTime(startTime), + }), testTags...) + + span, ctx := tracer.StartSpanFromContext(context.Background(), operationName, testOpts...) + if suite.module.session != nil { + span.SetTag(constants.TestSessionIDTag, fmt.Sprint(suite.module.session.sessionID)) + } + span.SetTag(constants.TestModuleIDTag, fmt.Sprint(suite.module.moduleID)) + span.SetTag(constants.TestSuiteIDTag, fmt.Sprint(suite.suiteID)) + + t := &tslvTest{ + suite: suite, + name: name, + ciVisibilityCommon: ciVisibilityCommon{ + startTime: startTime, + tags: testTags, + span: span, + ctx: ctx, + }, + } + + // Ensure to close everything before CI visibility exits. In CI visibility mode, we try to never lose data. + PushCiVisibilityCloseAction(func() { t.Close(ResultStatusFail) }) + + return t +} + +// Name returns the name of the test. +func (t *tslvTest) Name() string { return t.name } + +// Suite returns the suite to which the test belongs. +func (t *tslvTest) Suite() DdTestSuite { return t.suite } + +// Close closes the test with the given status and sets the finish time to the current time. +func (t *tslvTest) Close(status TestResultStatus) { t.CloseWithFinishTime(status, time.Now()) } + +// CloseWithFinishTime closes the test with the given status and finish time. +func (t *tslvTest) CloseWithFinishTime(status TestResultStatus, finishTime time.Time) { + t.CloseWithFinishTimeAndSkipReason(status, finishTime, "") +} + +// CloseWithFinishTimeAndSkipReason closes the test with the given status, finish time, and skip reason. +func (t *tslvTest) CloseWithFinishTimeAndSkipReason(status TestResultStatus, finishTime time.Time, skipReason string) { + t.mutex.Lock() + defer t.mutex.Unlock() + if t.closed { + return + } + + switch status { + case ResultStatusPass: + t.span.SetTag(constants.TestStatus, constants.TestStatusPass) + case ResultStatusFail: + t.span.SetTag(constants.TestStatus, constants.TestStatusFail) + case ResultStatusSkip: + t.span.SetTag(constants.TestStatus, constants.TestStatusSkip) + } + + if skipReason != "" { + t.span.SetTag(constants.TestSkipReason, skipReason) + } + + t.span.Finish(tracer.FinishTime(finishTime)) + t.closed = true +} + +// SetError sets an error on the test and marks the suite and module as having an error. +func (t *tslvTest) SetError(err error) { + t.ciVisibilityCommon.SetError(err) + t.Suite().SetTag(ext.Error, true) + t.Suite().Module().SetTag(ext.Error, true) +} + +// SetErrorInfo sets detailed error information on the test and marks the suite and module as having an error. +func (t *tslvTest) SetErrorInfo(errType string, message string, callstack string) { + t.ciVisibilityCommon.SetErrorInfo(errType, message, callstack) + t.Suite().SetTag(ext.Error, true) + t.Suite().Module().SetTag(ext.Error, true) +} + +// SetTestFunc sets the function to be tested and records its source location. +func (t *tslvTest) SetTestFunc(fn *runtime.Func) { + if fn == nil { + return + } + + file, line := fn.FileLine(fn.Entry()) + file = utils.GetRelativePathFromCITagsSourceRoot(file) + t.SetTag(constants.TestSourceFile, file) + t.SetTag(constants.TestSourceStartLine, line) + + codeOwners := utils.GetCodeOwners() + if codeOwners != nil { + match, found := codeOwners.Match("/" + file) + if found { + t.SetTag(constants.TestCodeOwners, match.GetOwnersString()) + } + } +} + +// SetBenchmarkData sets benchmark data for the test. +func (t *tslvTest) SetBenchmarkData(measureType string, data map[string]any) { + t.span.SetTag(constants.TestType, constants.TestTypeBenchmark) + for k, v := range data { + t.span.SetTag(fmt.Sprintf("benchmark.%s.%s", measureType, k), v) + } +} diff --git a/internal/civisibility/integrations/manual_api_ddtestmodule.go b/internal/civisibility/integrations/manual_api_ddtestmodule.go new file mode 100644 index 0000000000..9f8746f4e3 --- /dev/null +++ b/internal/civisibility/integrations/manual_api_ddtestmodule.go @@ -0,0 +1,140 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "fmt" + "strings" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" +) + +// Test Module + +// Ensures that tslvTestModule implements the DdTestModule interface. +var _ DdTestModule = (*tslvTestModule)(nil) + +// tslvTestModule implements the DdTestModule interface and represents a module within a test session. +type tslvTestModule struct { + ciVisibilityCommon + session *tslvTestSession + moduleID uint64 + name string + framework string + + suites map[string]DdTestSuite +} + +// createTestModule initializes a new test module within a given session. +func createTestModule(session *tslvTestSession, name string, framework string, frameworkVersion string, startTime time.Time) DdTestModule { + // Ensure CI visibility is properly configured. + EnsureCiVisibilityInitialization() + + operationName := "test_module" + if framework != "" { + operationName = fmt.Sprintf("%s.%s", strings.ToLower(framework), operationName) + } + + resourceName := name + + var sessionTags []tracer.StartSpanOption + if session != nil { + sessionTags = session.tags + } + + // Module tags should include session tags so the backend can calculate the session fingerprint from the module. + moduleTags := append(sessionTags, []tracer.StartSpanOption{ + tracer.Tag(constants.TestType, constants.TestTypeTest), + tracer.Tag(constants.TestModule, name), + tracer.Tag(constants.TestFramework, framework), + tracer.Tag(constants.TestFrameworkVersion, frameworkVersion), + }...) + + testOpts := append(fillCommonTags([]tracer.StartSpanOption{ + tracer.ResourceName(resourceName), + tracer.SpanType(constants.SpanTypeTestModule), + tracer.StartTime(startTime), + }), moduleTags...) + + span, ctx := tracer.StartSpanFromContext(context.Background(), operationName, testOpts...) + moduleID := span.Context().SpanID() + if session != nil { + span.SetTag(constants.TestSessionIDTag, fmt.Sprint(session.sessionID)) + } + span.SetTag(constants.TestModuleIDTag, fmt.Sprint(moduleID)) + + module := &tslvTestModule{ + session: session, + moduleID: moduleID, + name: name, + framework: framework, + suites: map[string]DdTestSuite{}, + ciVisibilityCommon: ciVisibilityCommon{ + startTime: startTime, + tags: moduleTags, + span: span, + ctx: ctx, + }, + } + + // Ensure to close everything before CI visibility exits. In CI visibility mode, we try to never lose data. + PushCiVisibilityCloseAction(func() { module.Close() }) + + return module +} + +// Name returns the name of the test module. +func (t *tslvTestModule) Name() string { return t.name } + +// Framework returns the testing framework used by the test module. +func (t *tslvTestModule) Framework() string { return t.framework } + +// Session returns the test session to which the test module belongs. +func (t *tslvTestModule) Session() DdTestSession { return t.session } + +// Close closes the test module and sets the finish time to the current time. +func (t *tslvTestModule) Close() { t.CloseWithFinishTime(time.Now()) } + +// CloseWithFinishTime closes the test module with the given finish time. +func (t *tslvTestModule) CloseWithFinishTime(finishTime time.Time) { + t.mutex.Lock() + defer t.mutex.Unlock() + if t.closed { + return + } + + for _, suite := range t.suites { + suite.Close() + } + t.suites = map[string]DdTestSuite{} + + t.span.Finish(tracer.FinishTime(finishTime)) + t.closed = true +} + +// GetOrCreateSuite returns an existing suite or creates a new one with the given name. +func (t *tslvTestModule) GetOrCreateSuite(name string) DdTestSuite { + return t.GetOrCreateSuiteWithStartTime(name, time.Now()) +} + +// GetOrCreateSuiteWithStartTime returns an existing suite or creates a new one with the given name and start time. +func (t *tslvTestModule) GetOrCreateSuiteWithStartTime(name string, startTime time.Time) DdTestSuite { + t.mutex.Lock() + defer t.mutex.Unlock() + + var suite DdTestSuite + if v, ok := t.suites[name]; ok { + suite = v + } else { + suite = createTestSuite(t, name, startTime) + t.suites[name] = suite + } + + return suite +} diff --git a/internal/civisibility/integrations/manual_api_ddtestsession.go b/internal/civisibility/integrations/manual_api_ddtestsession.go new file mode 100644 index 0000000000..807cd835da --- /dev/null +++ b/internal/civisibility/integrations/manual_api_ddtestsession.go @@ -0,0 +1,170 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/utils" +) + +// Test Session + +// Ensures that tslvTestSession implements the DdTestSession interface. +var _ DdTestSession = (*tslvTestSession)(nil) + +// tslvTestSession implements the DdTestSession interface and represents a session for a set of tests. +type tslvTestSession struct { + ciVisibilityCommon + sessionID uint64 + command string + workingDirectory string + framework string + + modules map[string]DdTestModule +} + +// CreateTestSession initializes a new test session. It automatically determines the command and working directory. +func CreateTestSession() DdTestSession { + var cmd string + if len(os.Args) == 1 { + cmd = filepath.Base(os.Args[0]) + } else { + cmd = fmt.Sprintf("%s %s ", filepath.Base(os.Args[0]), strings.Join(os.Args[1:], " ")) + } + + // Filter out some parameters to make the command more stable. + cmd = regexp.MustCompile(`(?si)-test.gocoverdir=(.*)\s`).ReplaceAllString(cmd, "") + cmd = regexp.MustCompile(`(?si)-test.v=(.*)\s`).ReplaceAllString(cmd, "") + cmd = regexp.MustCompile(`(?si)-test.testlogfile=(.*)\s`).ReplaceAllString(cmd, "") + cmd = strings.TrimSpace(cmd) + wd, err := os.Getwd() + if err == nil { + wd = utils.GetRelativePathFromCITagsSourceRoot(wd) + } + return CreateTestSessionWith(cmd, wd, "", time.Now()) +} + +// CreateTestSessionWith initializes a new test session with specified command, working directory, framework, and start time. +func CreateTestSessionWith(command string, workingDirectory string, framework string, startTime time.Time) DdTestSession { + // Ensure CI visibility is properly configured. + EnsureCiVisibilityInitialization() + + operationName := "test_session" + if framework != "" { + operationName = fmt.Sprintf("%s.%s", strings.ToLower(framework), operationName) + } + + resourceName := fmt.Sprintf("%s.%s", operationName, command) + + sessionTags := []tracer.StartSpanOption{ + tracer.Tag(constants.TestType, constants.TestTypeTest), + tracer.Tag(constants.TestCommand, command), + tracer.Tag(constants.TestCommandWorkingDirectory, workingDirectory), + } + + testOpts := append(fillCommonTags([]tracer.StartSpanOption{ + tracer.ResourceName(resourceName), + tracer.SpanType(constants.SpanTypeTestSession), + tracer.StartTime(startTime), + }), sessionTags...) + + span, ctx := tracer.StartSpanFromContext(context.Background(), operationName, testOpts...) + sessionID := span.Context().SpanID() + span.SetTag(constants.TestSessionIDTag, fmt.Sprint(sessionID)) + + s := &tslvTestSession{ + sessionID: sessionID, + command: command, + workingDirectory: workingDirectory, + framework: framework, + modules: map[string]DdTestModule{}, + ciVisibilityCommon: ciVisibilityCommon{ + startTime: startTime, + tags: sessionTags, + span: span, + ctx: ctx, + }, + } + + // Ensure to close everything before CI visibility exits. In CI visibility mode, we try to never lose data. + PushCiVisibilityCloseAction(func() { s.Close(1) }) + + return s +} + +// Command returns the command used to run the test session. +func (t *tslvTestSession) Command() string { return t.command } + +// Framework returns the testing framework used in the test session. +func (t *tslvTestSession) Framework() string { return t.framework } + +// WorkingDirectory returns the working directory of the test session. +func (t *tslvTestSession) WorkingDirectory() string { return t.workingDirectory } + +// Close closes the test session with the given exit code and sets the finish time to the current time. +func (t *tslvTestSession) Close(exitCode int) { t.CloseWithFinishTime(exitCode, time.Now()) } + +// CloseWithFinishTime closes the test session with the given exit code and finish time. +func (t *tslvTestSession) CloseWithFinishTime(exitCode int, finishTime time.Time) { + t.mutex.Lock() + defer t.mutex.Unlock() + if t.closed { + return + } + + for _, m := range t.modules { + m.Close() + } + t.modules = map[string]DdTestModule{} + + t.span.SetTag(constants.TestCommandExitCode, exitCode) + if exitCode == 0 { + t.span.SetTag(constants.TestStatus, constants.TestStatusPass) + } else { + t.SetErrorInfo("ExitCode", "exit code is not zero.", "") + t.span.SetTag(constants.TestStatus, constants.TestStatusFail) + } + + t.span.Finish(tracer.FinishTime(finishTime)) + t.closed = true + + tracer.Flush() +} + +// GetOrCreateModule returns an existing module or creates a new one with the given name. +func (t *tslvTestSession) GetOrCreateModule(name string) DdTestModule { + return t.GetOrCreateModuleWithFramework(name, "", "") +} + +// GetOrCreateModuleWithFramework returns an existing module or creates a new one with the given name, framework, and framework version. +func (t *tslvTestSession) GetOrCreateModuleWithFramework(name string, framework string, frameworkVersion string) DdTestModule { + return t.GetOrCreateModuleWithFrameworkAndStartTime(name, framework, frameworkVersion, time.Now()) +} + +// GetOrCreateModuleWithFrameworkAndStartTime returns an existing module or creates a new one with the given name, framework, framework version, and start time. +func (t *tslvTestSession) GetOrCreateModuleWithFrameworkAndStartTime(name string, framework string, frameworkVersion string, startTime time.Time) DdTestModule { + t.mutex.Lock() + defer t.mutex.Unlock() + + var mod DdTestModule + if v, ok := t.modules[name]; ok { + mod = v + } else { + mod = createTestModule(t, name, framework, frameworkVersion, startTime) + t.modules[name] = mod + } + + return mod +} diff --git a/internal/civisibility/integrations/manual_api_ddtestsuite.go b/internal/civisibility/integrations/manual_api_ddtestsuite.go new file mode 100644 index 0000000000..9a1857dd21 --- /dev/null +++ b/internal/civisibility/integrations/manual_api_ddtestsuite.go @@ -0,0 +1,120 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "fmt" + "strings" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" +) + +// Test Suite + +// Ensures that tslvTestSuite implements the DdTestSuite interface. +var _ DdTestSuite = (*tslvTestSuite)(nil) + +// tslvTestSuite implements the DdTestSuite interface and represents a suite of tests within a module. +type tslvTestSuite struct { + ciVisibilityCommon + module *tslvTestModule + suiteID uint64 + name string +} + +// createTestSuite initializes a new test suite within a given module. +func createTestSuite(module *tslvTestModule, name string, startTime time.Time) DdTestSuite { + if module == nil { + return nil + } + + operationName := "test_suite" + if module.framework != "" { + operationName = fmt.Sprintf("%s.%s", strings.ToLower(module.framework), operationName) + } + + resourceName := name + + // Suite tags should include module and session tags so the backend can calculate the module and session fingerprint from the suite. + suiteTags := append(module.tags, tracer.Tag(constants.TestSuite, name)) + testOpts := append(fillCommonTags([]tracer.StartSpanOption{ + tracer.ResourceName(resourceName), + tracer.SpanType(constants.SpanTypeTestSuite), + tracer.StartTime(startTime), + }), suiteTags...) + + span, ctx := tracer.StartSpanFromContext(context.Background(), operationName, testOpts...) + suiteID := span.Context().SpanID() + if module.session != nil { + span.SetTag(constants.TestSessionIDTag, fmt.Sprint(module.session.sessionID)) + } + span.SetTag(constants.TestModuleIDTag, fmt.Sprint(module.moduleID)) + span.SetTag(constants.TestSuiteIDTag, fmt.Sprint(suiteID)) + + suite := &tslvTestSuite{ + module: module, + suiteID: suiteID, + name: name, + ciVisibilityCommon: ciVisibilityCommon{ + startTime: startTime, + tags: suiteTags, + span: span, + ctx: ctx, + }, + } + + // Ensure to close everything before CI visibility exits. In CI visibility mode, we try to never lose data. + PushCiVisibilityCloseAction(func() { suite.Close() }) + + return suite +} + +// Name returns the name of the test suite. +func (t *tslvTestSuite) Name() string { return t.name } + +// Module returns the module to which the test suite belongs. +func (t *tslvTestSuite) Module() DdTestModule { return t.module } + +// Close closes the test suite and sets the finish time to the current time. +func (t *tslvTestSuite) Close() { t.CloseWithFinishTime(time.Now()) } + +// CloseWithFinishTime closes the test suite with the given finish time. +func (t *tslvTestSuite) CloseWithFinishTime(finishTime time.Time) { + t.mutex.Lock() + defer t.mutex.Unlock() + if t.closed { + return + } + + t.span.Finish(tracer.FinishTime(finishTime)) + t.closed = true +} + +// SetError sets an error on the test suite and marks the module as having an error. +func (t *tslvTestSuite) SetError(err error) { + t.ciVisibilityCommon.SetError(err) + t.Module().SetTag(ext.Error, true) +} + +// SetErrorInfo sets detailed error information on the test suite and marks the module as having an error. +func (t *tslvTestSuite) SetErrorInfo(errType string, message string, callstack string) { + t.ciVisibilityCommon.SetErrorInfo(errType, message, callstack) + t.Module().SetTag(ext.Error, true) +} + +// CreateTest creates a new test with the given name and sets the start time to the current time. +func (t *tslvTestSuite) CreateTest(name string) DdTest { + return t.CreateTestWithStartTime(name, time.Now()) +} + +// CreateTestWithStartTime creates a new test with the given name and start time. +func (t *tslvTestSuite) CreateTestWithStartTime(name string, startTime time.Time) DdTest { + return createTest(t, name, startTime) +} diff --git a/internal/civisibility/integrations/manual_api_mocktracer_test.go b/internal/civisibility/integrations/manual_api_mocktracer_test.go new file mode 100644 index 0000000000..afb6d321e4 --- /dev/null +++ b/internal/civisibility/integrations/manual_api_mocktracer_test.go @@ -0,0 +1,277 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "errors" + "os" + "runtime" + "testing" + "time" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/internal/civisibility/constants" + + "github.com/stretchr/testify/assert" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" +) + +var mockTracer mocktracer.Tracer + +func TestMain(m *testing.M) { + // Initialize civisibility using the mocktracer for testing + mockTracer = InitializeCIVisibilityMock() + + // Run tests + os.Exit(m.Run()) +} + +func createDDTestSession(now time.Time) DdTestSession { + session := CreateTestSessionWith("my-command", "/tmp/wd", "my-testing-framework", now) + session.SetTag("my-tag", "my-value") + return session +} + +func createDDTestModule(now time.Time) (DdTestSession, DdTestModule) { + session := createDDTestSession(now) + module := session.GetOrCreateModuleWithFrameworkAndStartTime("my-module", "my-module-framework", "framework-version", now) + module.SetTag("my-tag", "my-value") + return session, module +} + +func createDDTestSuite(now time.Time) (DdTestSession, DdTestModule, DdTestSuite) { + session, module := createDDTestModule(now) + suite := module.GetOrCreateSuiteWithStartTime("my-suite", now) + suite.SetTag("my-tag", "my-value") + return session, module, suite +} + +func createDDTest(now time.Time) (DdTestSession, DdTestModule, DdTestSuite, DdTest) { + session, module, suite := createDDTestSuite(now) + test := suite.CreateTestWithStartTime("my-test", now) + test.SetTag("my-tag", "my-value") + return session, module, suite, test +} + +func commonAssertions(assert *assert.Assertions, sessionSpan mocktracer.Span) { + tags := map[string]interface{}{ + "my-tag": "my-value", + constants.Origin: constants.CIAppTestOrigin, + constants.TestType: constants.TestTypeTest, + constants.TestCommand: "my-command", + } + + spanTags := sessionSpan.Tags() + + assert.Subset(spanTags, tags) + assert.Contains(spanTags, constants.OSPlatform) + assert.Contains(spanTags, constants.OSArchitecture) + assert.Contains(spanTags, constants.OSVersion) + assert.Contains(spanTags, constants.RuntimeVersion) + assert.Contains(spanTags, constants.RuntimeName) + assert.Contains(spanTags, constants.GitRepositoryURL) + assert.Contains(spanTags, constants.GitCommitSHA) +} + +func TestSession(t *testing.T) { + mockTracer.Reset() + assert := assert.New(t) + + now := time.Now() + session := createDDTestSession(now) + assert.NotNil(session.Context()) + assert.Equal("my-command", session.Command()) + assert.Equal("/tmp/wd", session.WorkingDirectory()) + assert.Equal("my-testing-framework", session.Framework()) + assert.Equal(now, session.StartTime()) + + session.Close(42) + + finishedSpans := mockTracer.FinishedSpans() + assert.Equal(1, len(finishedSpans)) + sessionAssertions(assert, now, finishedSpans[0]) + + // session already closed, this is a no-op + session.Close(0) +} + +func sessionAssertions(assert *assert.Assertions, now time.Time, sessionSpan mocktracer.Span) { + assert.Equal(now, sessionSpan.StartTime()) + assert.Equal("my-testing-framework.test_session", sessionSpan.OperationName()) + + tags := map[string]interface{}{ + ext.ResourceName: "my-testing-framework.test_session.my-command", + ext.Error: true, + ext.ErrorType: "ExitCode", + ext.ErrorMsg: "exit code is not zero.", + ext.SpanType: constants.SpanTypeTestSession, + constants.TestStatus: constants.TestStatusFail, + constants.TestCommandExitCode: 42, + } + + spanTags := sessionSpan.Tags() + + assert.Subset(spanTags, tags) + assert.Contains(spanTags, constants.TestSessionIDTag) + commonAssertions(assert, sessionSpan) +} + +func TestModule(t *testing.T) { + mockTracer.Reset() + assert := assert.New(t) + + now := time.Now() + session, module := createDDTestModule(now) + defer func() { session.Close(0) }() + module.SetErrorInfo("my-type", "my-message", "my-stack") + + assert.NotNil(module.Context()) + assert.Equal("my-module", module.Name()) + assert.Equal("my-module-framework", module.Framework()) + assert.Equal(now, module.StartTime()) + assert.Equal(session, module.Session()) + + module.Close() + + finishedSpans := mockTracer.FinishedSpans() + assert.Equal(1, len(finishedSpans)) + moduleAssertions(assert, now, finishedSpans[0]) + + //no-op call + module.Close() +} + +func moduleAssertions(assert *assert.Assertions, now time.Time, moduleSpan mocktracer.Span) { + assert.Equal(now, moduleSpan.StartTime()) + assert.Equal("my-module-framework.test_module", moduleSpan.OperationName()) + + tags := map[string]interface{}{ + ext.ResourceName: "my-module", + ext.Error: true, + ext.ErrorType: "my-type", + ext.ErrorMsg: "my-message", + ext.ErrorStack: "my-stack", + ext.SpanType: constants.SpanTypeTestModule, + constants.TestModule: "my-module", + } + + spanTags := moduleSpan.Tags() + + assert.Subset(spanTags, tags) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + commonAssertions(assert, moduleSpan) +} + +func TestSuite(t *testing.T) { + mockTracer.Reset() + assert := assert.New(t) + + now := time.Now() + session, module, suite := createDDTestSuite(now) + defer func() { + session.Close(0) + module.Close() + }() + suite.SetErrorInfo("my-type", "my-message", "my-stack") + + assert.NotNil(suite.Context()) + assert.Equal("my-suite", suite.Name()) + assert.Equal(now, suite.StartTime()) + assert.Equal(module, suite.Module()) + + suite.Close() + + finishedSpans := mockTracer.FinishedSpans() + assert.Equal(1, len(finishedSpans)) + suiteAssertions(assert, now, finishedSpans[0]) + + //no-op call + suite.Close() +} + +func suiteAssertions(assert *assert.Assertions, now time.Time, suiteSpan mocktracer.Span) { + assert.Equal(now, suiteSpan.StartTime()) + assert.Equal("my-module-framework.test_suite", suiteSpan.OperationName()) + + tags := map[string]interface{}{ + ext.ResourceName: "my-suite", + ext.Error: true, + ext.ErrorType: "my-type", + ext.ErrorMsg: "my-message", + ext.ErrorStack: "my-stack", + ext.SpanType: constants.SpanTypeTestSuite, + constants.TestModule: "my-module", + constants.TestSuite: "my-suite", + } + + spanTags := suiteSpan.Tags() + + assert.Subset(spanTags, tags) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + assert.Contains(spanTags, constants.TestSuiteIDTag) + commonAssertions(assert, suiteSpan) +} + +func Test(t *testing.T) { + mockTracer.Reset() + assert := assert.New(t) + + now := time.Now() + session, module, suite, test := createDDTest(now) + defer func() { + session.Close(0) + module.Close() + suite.Close() + }() + test.SetError(errors.New("we keep the last error")) + test.SetErrorInfo("my-type", "my-message", "my-stack") + pc, _, _, _ := runtime.Caller(0) + test.SetTestFunc(runtime.FuncForPC(pc)) + + assert.NotNil(test.Context()) + assert.Equal("my-test", test.Name()) + assert.Equal(now, test.StartTime()) + assert.Equal(suite, test.Suite()) + + test.Close(ResultStatusPass) + + finishedSpans := mockTracer.FinishedSpans() + assert.Equal(1, len(finishedSpans)) + testAssertions(assert, now, finishedSpans[0]) + + //no-op call + test.Close(ResultStatusSkip) +} + +func testAssertions(assert *assert.Assertions, now time.Time, testSpan mocktracer.Span) { + assert.Equal(now, testSpan.StartTime()) + assert.Equal("my-module-framework.test", testSpan.OperationName()) + + tags := map[string]interface{}{ + ext.ResourceName: "my-suite.my-test", + ext.Error: true, + ext.ErrorType: "my-type", + ext.ErrorMsg: "my-message", + ext.ErrorStack: "my-stack", + ext.SpanType: constants.SpanTypeTest, + constants.TestModule: "my-module", + constants.TestSuite: "my-suite", + constants.TestName: "my-test", + constants.TestStatus: constants.TestStatusPass, + } + + spanTags := testSpan.Tags() + + assert.Subset(spanTags, tags) + assert.Contains(spanTags, constants.TestSessionIDTag) + assert.Contains(spanTags, constants.TestModuleIDTag) + assert.Contains(spanTags, constants.TestSuiteIDTag) + assert.Contains(spanTags, constants.TestSourceFile) + assert.Contains(spanTags, constants.TestSourceStartLine) + commonAssertions(assert, testSpan) +} diff --git a/internal/civisibility/integrations/manual_api_test.go b/internal/civisibility/integrations/manual_api_test.go new file mode 100644 index 0000000000..2bb0a4ecb3 --- /dev/null +++ b/internal/civisibility/integrations/manual_api_test.go @@ -0,0 +1,329 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package integrations + +import ( + "context" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Mocking the ddTslvEvent interface +type MockDdTslvEvent struct { + mock.Mock +} + +func (m *MockDdTslvEvent) Context() context.Context { + args := m.Called() + return args.Get(0).(context.Context) +} + +func (m *MockDdTslvEvent) StartTime() time.Time { + args := m.Called() + return args.Get(0).(time.Time) +} + +func (m *MockDdTslvEvent) SetError(err error) { + m.Called(err) +} + +func (m *MockDdTslvEvent) SetErrorInfo(errType string, message string, callstack string) { + m.Called(errType, message, callstack) +} + +func (m *MockDdTslvEvent) SetTag(key string, value interface{}) { + m.Called(key, value) +} + +// Mocking the DdTest interface +type MockDdTest struct { + MockDdTslvEvent + mock.Mock +} + +func (m *MockDdTest) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTest) Suite() DdTestSuite { + args := m.Called() + return args.Get(0).(DdTestSuite) +} + +func (m *MockDdTest) Close(status TestResultStatus) { + m.Called(status) +} + +func (m *MockDdTest) CloseWithFinishTime(status TestResultStatus, finishTime time.Time) { + m.Called(status, finishTime) +} + +func (m *MockDdTest) CloseWithFinishTimeAndSkipReason(status TestResultStatus, finishTime time.Time, skipReason string) { + m.Called(status, finishTime, skipReason) +} + +func (m *MockDdTest) SetTestFunc(fn *runtime.Func) { + m.Called(fn) +} + +func (m *MockDdTest) SetBenchmarkData(measureType string, data map[string]any) { + m.Called(measureType, data) +} + +// Mocking the DdTestSession interface +type MockDdTestSession struct { + MockDdTslvEvent + mock.Mock +} + +func (m *MockDdTestSession) Command() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestSession) Framework() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestSession) WorkingDirectory() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestSession) Close(exitCode int) { + m.Called(exitCode) +} + +func (m *MockDdTestSession) CloseWithFinishTime(exitCode int, finishTime time.Time) { + m.Called(exitCode, finishTime) +} + +func (m *MockDdTestSession) GetOrCreateModule(name string) DdTestModule { + args := m.Called(name) + return args.Get(0).(DdTestModule) +} + +func (m *MockDdTestSession) GetOrCreateModuleWithFramework(name string, framework string, frameworkVersion string) DdTestModule { + args := m.Called(name, framework, frameworkVersion) + return args.Get(0).(DdTestModule) +} + +func (m *MockDdTestSession) GetOrCreateModuleWithFrameworkAndStartTime(name string, framework string, frameworkVersion string, startTime time.Time) DdTestModule { + args := m.Called(name, framework, frameworkVersion, startTime) + return args.Get(0).(DdTestModule) +} + +// Mocking the DdTestModule interface +type MockDdTestModule struct { + MockDdTslvEvent + mock.Mock +} + +func (m *MockDdTestModule) Session() DdTestSession { + args := m.Called() + return args.Get(0).(DdTestSession) +} + +func (m *MockDdTestModule) Framework() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestModule) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestModule) Close() { + m.Called() +} + +func (m *MockDdTestModule) CloseWithFinishTime(finishTime time.Time) { + m.Called(finishTime) +} + +func (m *MockDdTestModule) GetOrCreateSuite(name string) DdTestSuite { + args := m.Called(name) + return args.Get(0).(DdTestSuite) +} + +func (m *MockDdTestModule) GetOrCreateSuiteWithStartTime(name string, startTime time.Time) DdTestSuite { + args := m.Called(name, startTime) + return args.Get(0).(DdTestSuite) +} + +// Mocking the DdTestSuite interface +type MockDdTestSuite struct { + MockDdTslvEvent + mock.Mock +} + +func (m *MockDdTestSuite) Module() DdTestModule { + args := m.Called() + return args.Get(0).(DdTestModule) +} + +func (m *MockDdTestSuite) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockDdTestSuite) Close() { + m.Called() +} + +func (m *MockDdTestSuite) CloseWithFinishTime(finishTime time.Time) { + m.Called(finishTime) +} + +func (m *MockDdTestSuite) CreateTest(name string) DdTest { + args := m.Called(name) + return args.Get(0).(DdTest) +} + +func (m *MockDdTestSuite) CreateTestWithStartTime(name string, startTime time.Time) DdTest { + args := m.Called(name, startTime) + return args.Get(0).(DdTest) +} + +// Unit tests +func TestDdTestSession(t *testing.T) { + mockSession := new(MockDdTestSession) + mockSession.On("Command").Return("test-command") + mockSession.On("Framework").Return("test-framework") + mockSession.On("WorkingDirectory").Return("/path/to/working/dir") + mockSession.On("Close", 0).Return() + mockSession.On("CloseWithFinishTime", 0, mock.Anything).Return() + mockSession.On("GetOrCreateModule", "test-module").Return(new(MockDdTestModule)) + mockSession.On("GetOrCreateModuleWithFramework", "test-module", "test-framework", "1.0").Return(new(MockDdTestModule)) + mockSession.On("GetOrCreateModuleWithFrameworkAndStartTime", "test-module", "test-framework", "1.0", mock.Anything).Return(new(MockDdTestModule)) + + session := (DdTestSession)(mockSession) + assert.Equal(t, "test-command", session.Command()) + assert.Equal(t, "test-framework", session.Framework()) + assert.Equal(t, "/path/to/working/dir", session.WorkingDirectory()) + + session.Close(0) + mockSession.AssertCalled(t, "Close", 0) + + now := time.Now() + session.CloseWithFinishTime(0, now) + mockSession.AssertCalled(t, "CloseWithFinishTime", 0, now) + + module := session.GetOrCreateModule("test-module") + assert.NotNil(t, module) + mockSession.AssertCalled(t, "GetOrCreateModule", "test-module") + + module = session.GetOrCreateModuleWithFramework("test-module", "test-framework", "1.0") + assert.NotNil(t, module) + mockSession.AssertCalled(t, "GetOrCreateModuleWithFramework", "test-module", "test-framework", "1.0") + + module = session.GetOrCreateModuleWithFrameworkAndStartTime("test-module", "test-framework", "1.0", now) + assert.NotNil(t, module) + mockSession.AssertCalled(t, "GetOrCreateModuleWithFrameworkAndStartTime", "test-module", "test-framework", "1.0", now) +} + +func TestDdTestModule(t *testing.T) { + mockModule := new(MockDdTestModule) + mockModule.On("Session").Return(new(MockDdTestSession)) + mockModule.On("Framework").Return("test-framework") + mockModule.On("Name").Return("test-module") + mockModule.On("Close").Return() + mockModule.On("CloseWithFinishTime", mock.Anything).Return() + mockModule.On("GetOrCreateSuite", "test-suite").Return(new(MockDdTestSuite)) + mockModule.On("GetOrCreateSuiteWithStartTime", "test-suite", mock.Anything).Return(new(MockDdTestSuite)) + + module := (DdTestModule)(mockModule) + + assert.Equal(t, "test-framework", module.Framework()) + assert.Equal(t, "test-module", module.Name()) + + module.Close() + mockModule.AssertCalled(t, "Close") + + now := time.Now() + module.CloseWithFinishTime(now) + mockModule.AssertCalled(t, "CloseWithFinishTime", now) + + suite := module.GetOrCreateSuite("test-suite") + assert.NotNil(t, suite) + mockModule.AssertCalled(t, "GetOrCreateSuite", "test-suite") + + suite = module.GetOrCreateSuiteWithStartTime("test-suite", now) + assert.NotNil(t, suite) + mockModule.AssertCalled(t, "GetOrCreateSuiteWithStartTime", "test-suite", now) +} + +func TestDdTestSuite(t *testing.T) { + mockSuite := new(MockDdTestSuite) + mockSuite.On("Module").Return(new(MockDdTestModule)) + mockSuite.On("Name").Return("test-suite") + mockSuite.On("Close").Return() + mockSuite.On("CloseWithFinishTime", mock.Anything).Return() + mockSuite.On("CreateTest", "test-name").Return(new(MockDdTest)) + mockSuite.On("CreateTestWithStartTime", "test-name", mock.Anything).Return(new(MockDdTest)) + + suite := (DdTestSuite)(mockSuite) + + assert.Equal(t, "test-suite", suite.Name()) + + suite.Close() + mockSuite.AssertCalled(t, "Close") + + now := time.Now() + suite.CloseWithFinishTime(now) + mockSuite.AssertCalled(t, "CloseWithFinishTime", now) + + test := suite.CreateTest("test-name") + assert.NotNil(t, test) + mockSuite.AssertCalled(t, "CreateTest", "test-name") + + test = suite.CreateTestWithStartTime("test-name", now) + assert.NotNil(t, test) + mockSuite.AssertCalled(t, "CreateTestWithStartTime", "test-name", now) +} + +func TestDdTest(t *testing.T) { + mockTest := new(MockDdTest) + mockTest.On("Name").Return("test-name") + mockTest.On("Suite").Return(new(MockDdTestSuite)) + mockTest.On("Close", ResultStatusPass).Return() + mockTest.On("CloseWithFinishTime", ResultStatusPass, mock.Anything).Return() + mockTest.On("CloseWithFinishTimeAndSkipReason", ResultStatusSkip, mock.Anything, "SkipReason").Return() + mockTest.On("SetTestFunc", mock.Anything).Return() + mockTest.On("SetBenchmarkData", "measure-type", mock.Anything).Return() + + test := (DdTest)(mockTest) + + assert.Equal(t, "test-name", test.Name()) + + suite := test.Suite() + assert.NotNil(t, suite) + + test.Close(ResultStatusPass) + mockTest.AssertCalled(t, "Close", ResultStatusPass) + + now := time.Now() + test.CloseWithFinishTime(ResultStatusPass, now) + mockTest.AssertCalled(t, "CloseWithFinishTime", ResultStatusPass, now) + + skipReason := "SkipReason" + test.CloseWithFinishTimeAndSkipReason(ResultStatusSkip, now, skipReason) + mockTest.AssertCalled(t, "CloseWithFinishTimeAndSkipReason", ResultStatusSkip, now, skipReason) + + test.SetTestFunc(nil) + mockTest.AssertCalled(t, "SetTestFunc", (*runtime.Func)(nil)) + + benchmarkData := map[string]any{"key": "value"} + test.SetBenchmarkData("measure-type", benchmarkData) + mockTest.AssertCalled(t, "SetBenchmarkData", "measure-type", benchmarkData) +}