From 7d72a0ba0e1b7aca9f12d04881a24616d4c23b1d Mon Sep 17 00:00:00 2001 From: Michael Fulbright <89205663+mfulb@users.noreply.github.com> Date: Tue, 22 Aug 2023 15:35:21 -0400 Subject: [PATCH] feat(testing): Implement EXPECT_SPAN_EVENTS_LIKE (#717) Adds a new feature (`EXPECT_SPAN_EVENTS_LIKE`) for the `integration_runner` which allows testing for the existence of a set of spans in the actual spans created. It differs from `EXPECT_SPAN_EVENTS` in that it is not necessary to list ALL expected spans. For tests involving PHP frameworks the number of generated spans can be in the hundreds so it is not practical to use `EXPECT_SPAN_EVENTS` and thus `EXPECT_SPAN_EVENTS_LIKE` was added. Here is an example: ``` /*EXPECT_SPAN_EVENTS_LIKE [ [ { "category": "generic", "type": "Span", "guid": "??", "traceId": "??", "transactionId": "??", "name": "WebTransaction\/Action\/dispatch", "timestamp": "??", "duration": "??", "priority": "??", "sampled": true, "nr.entryPoint": true, "transaction.name": "WebTransaction\/Action\/dispatch" }, {}, { "response.headers.contentType": "text\/html", "http.statusCode": 200, "response.statusCode": 200, "httpResponseCode": "200", "request.uri": "\/dispatch", "request.method": "GET", "request.headers.host": "127.0.0.1" } ], [ { "category": "generic", "type": "Span", "guid": "??", "traceId": "??", "transactionId": "??", "name": "Custom\/App\\Http\\Controllers\\JobOne::dispatch", "timestamp": "??", "duration": "??", "priority": "??", "sampled": true, "parentId": "??" }, {}, { "code.lineno": "??", "code.namespace": "App\\Http\\Controllers\\JobOne", "code.filepath": "??", "code.function": "dispatch" } ] ] */ ``` --------- Co-authored-by: Michal Nowacki --- src/newrelic/integration/parse.go | 5 ++ src/newrelic/integration/test.go | 121 ++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/newrelic/integration/parse.go b/src/newrelic/integration/parse.go index 43af09391..554972556 100644 --- a/src/newrelic/integration/parse.go +++ b/src/newrelic/integration/parse.go @@ -29,6 +29,7 @@ var ( "EXPECT_CUSTOM_EVENTS": parseCustomEvents, "EXPECT_ERROR_EVENTS": parseErrorEvents, "EXPECT_SPAN_EVENTS": parseSpanEvents, + "EXPECT_SPAN_EVENTS_LIKE": parseSpanEventsLike, "EXPECT_LOG_EVENTS": parseLogEvents, "EXPECT_METRICS": parseMetrics, "EXPECT": parseExpect, @@ -211,6 +212,10 @@ func parseSpanEvents(test *Test, content []byte) error { test.spanEvents = content return nil } +func parseSpanEventsLike(test *Test, content []byte) error { + test.spanEventsLike = content + return nil +} func parseLogEvents(test *Test, content []byte) error { test.logEvents = content return nil diff --git a/src/newrelic/integration/test.go b/src/newrelic/integration/test.go index 14d66bddc..4ce126a33 100644 --- a/src/newrelic/integration/test.go +++ b/src/newrelic/integration/test.go @@ -33,6 +33,7 @@ type Test struct { customEvents []byte errorEvents []byte spanEvents []byte + spanEventsLike []byte logEvents []byte metrics []byte slowSQLs []byte @@ -315,6 +316,122 @@ func (t *Test) compareResponseHeaders() { } } +// Handling EXPECT_SPAN_EVENTS_LIKE is different than normal payload compare so +// a different function is required +func (t *Test) compareSpanEventsLike(harvest *newrelic.Harvest) { + // convert array of expected spans JSON to interface representation + var x2 interface{} + + if nil == t.spanEventsLike { + return + } + + if err := json.Unmarshal(t.spanEventsLike, &x2); nil != err { + t.Fatal(fmt.Errorf("unable to parse expected spans like json for fuzzy matching: %v", err)) + return + } + + // expected will be represented as an array of "interface{}" + // each element will be the internal representation of the JSON + // for each expected span + // this is needed for the call to isFuzzyMatch Recursive later + // when comparing to the actual spans in their internal representation + expected := x2.([]interface{}) + + // now parse actual span data JSON into interface representation + var x1 interface{} + + es := *harvest.SpanEvents + id := newrelic.AgentRunID("?? agent run id") + actualJSON, err := es.Data(id, time.Now()) + if nil != err { + t.Fatal(fmt.Errorf("unable to access span event JSON data: %v", err)) + return + } + + // scrub actual spans + scrubjson := ScrubLineNumbers(actualJSON) + scrubjson = ScrubFilename(scrubjson, t.Path) + scrubjson = ScrubHost(scrubjson) + + // parse to internal format + if err := json.Unmarshal(scrubjson, &x1); nil != err { + t.Fatal(fmt.Errorf("unable to parse actual spans like json for fuzzy matching: %v", err)) + return + } + + // expect x1 to be of type "[]interface {}" which wraps the entire span event data + // within this generic array there will be: + // - a string of the form "?? agent run id" + // - a map container "events_seen" and "reservoir_size" + // - an array of arrays containing: + // - map containing main span data + // - map for attributes(?) + // - map of CLM data + + // test initial type is as expected + switch x1.(type) { + case []interface{}: + default: + t.Fatal(errors.New("span event data json doesnt match expected format")) + return + } + + // expect array of len 3 + v2, _ := x1.([]interface{}) + if 3 != len(v2) { + t.Fatal(errors.New("span event data json doesnt match expected format - expected 3 elements")) + return + } + + // get array of actual spans from 3rd element + actual := v2[2].([]interface{}) + + // check if expected JSON is present in actual data + // will call isFuzzyMatchRecursive with "interface" representations + numMatched := 0 + haveMatched := make([]bool, len(expected)) + for i := 0; i < len(expected); i++ { + haveMatched[i] = false + } + + for i := 0; i < len(actual); i++ { + // check each expected span (interface representation) against current actual span + // only iterate over unmatched expected spans + for j := 0; j < len(expected); j++ { + if haveMatched[j] { + continue + } + + err := isFuzzyMatchRecursive(expected[j], actual[i]) + if nil == err { + haveMatched[j] = true + numMatched++ + break + } + } + + if len(expected) == numMatched { + break + } + } + + for j := 0; j < len(expected); j++ { + if !haveMatched[j] { + actualPretty := bytes.Buffer{} + json.Indent(&actualPretty, actualJSON, "", " ") + expectedJSON, _ := json.Marshal(expected[j]) + + t.Fail(ComparisonFailure{ + Name: fmt.Sprintf("matching span event data like: unmatched expected span"), + Expect: string(expectedJSON), + Actual: actualPretty.String(), + }) + return + } + } +} + func (t *Test) comparePayload(expected json.RawMessage, pc newrelic.PayloadCreator, isMetrics bool) { if nil == expected { // No expected output has been specified: Anything passes. @@ -451,6 +568,10 @@ func (t *Test) Compare(harvest *newrelic.Harvest) { return } + // check for any "expected spans like" + t.compareSpanEventsLike(harvest) + + // check remaining payloads t.comparePayload(t.analyticEvents, harvest.TxnEvents, false) t.comparePayload(t.customEvents, harvest.CustomEvents, false) t.comparePayload(t.errorEvents, harvest.ErrorEvents, false)