From b8cb6ac048ddd6f43cadebf3ca9d7497e1ea0d94 Mon Sep 17 00:00:00 2001 From: Emilio Garcia Date: Tue, 14 Nov 2023 12:52:56 -0500 Subject: [PATCH] getting started with new header --- v3/internal/cat/headers.go | 1 + v3/internal/cat/synthetics.go | 106 +++++++++++++++++++-- v3/internal/cat/synthetics_test.go | 127 ++++++++++++++++++++++++++ v3/newrelic/cross_process_http.go | 5 + v3/newrelic/txn_cross_process.go | 48 +++++++++- v3/newrelic/txn_cross_process_test.go | 16 +++- 6 files changed, 293 insertions(+), 10 deletions(-) diff --git a/v3/internal/cat/headers.go b/v3/internal/cat/headers.go index 6ca05cd67..74f59f256 100644 --- a/v3/internal/cat/headers.go +++ b/v3/internal/cat/headers.go @@ -13,4 +13,5 @@ const ( NewRelicTxnName = "X-Newrelic-Transaction" NewRelicAppDataName = "X-Newrelic-App-Data" NewRelicSyntheticsName = "X-Newrelic-Synthetics" + NewRelicSyntheticsInfo = "X-Newrelic-Synthecics-Info" ) diff --git a/v3/internal/cat/synthetics.go b/v3/internal/cat/synthetics.go index b88c15476..59974f0a2 100644 --- a/v3/internal/cat/synthetics.go +++ b/v3/internal/cat/synthetics.go @@ -18,13 +18,30 @@ type SyntheticsHeader struct { MonitorID string } +// SyntheticsInfo represents a decoded synthetics info payload. +type SyntheticsInfo struct { + Version int + Type string + Initiator string + Attributes map[string]string +} + var ( - errInvalidSyntheticsJSON = errors.New("invalid synthetics JSON") - errInvalidSyntheticsVersion = errors.New("version is not a float64") - errInvalidSyntheticsAccountID = errors.New("account ID is not a float64") - errInvalidSyntheticsResourceID = errors.New("synthetics resource ID is not a string") - errInvalidSyntheticsJobID = errors.New("synthetics job ID is not a string") - errInvalidSyntheticsMonitorID = errors.New("synthetics monitor ID is not a string") + errInvalidSyntheticsJSON = errors.New("invalid synthetics JSON") + errInvalidSyntheticsInfoJSON = errors.New("invalid synthetics info JSON") + errInvalidSyntheticsVersion = errors.New("version is not a float64") + errInvalidSyntheticsAccountID = errors.New("account ID is not a float64") + errInvalidSyntheticsResourceID = errors.New("synthetics resource ID is not a string") + errInvalidSyntheticsJobID = errors.New("synthetics job ID is not a string") + errInvalidSyntheticsMonitorID = errors.New("synthetics monitor ID is not a string") + errInvalidSyntheticsInfoVersion = errors.New("synthetics info version is not a float64") + errMissingSyntheticsInfoVersion = errors.New("synthetics info version is missing from JSON object") + errInvalidSyntheticsInfoType = errors.New("synthetics info type is not a string") + errMissingSyntheticsInfoType = errors.New("synthetics info type is missing from JSON object") + errInvalidSyntheticsInfoInitiator = errors.New("synthetics info initiator is not a string") + errMissingSyntheticsInfoInitiator = errors.New("synthetics info initiator is missing from JSON object") + errInvalidSyntheticsInfoAttributes = errors.New("synthetics info attributes is not a map") + errInvalidSyntheticsInfoAttributeVal = errors.New("synthetics info keys and values must be strings") ) type errUnexpectedSyntheticsVersion int @@ -83,3 +100,80 @@ func (s *SyntheticsHeader) UnmarshalJSON(data []byte) error { return nil } + +const ( + versionKey = "version" + typeKey = "type" + initiatorKey = "initiator" + attributesKey = "attributes" +) + +// UnmarshalJSON unmarshalls a SyntheticsInfo from raw JSON. +func (s *SyntheticsInfo) UnmarshalJSON(data []byte) error { + var v any + + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + m, ok := v.(map[string]any) + if !ok { + return errInvalidSyntheticsInfoJSON + } + + version, ok := m[versionKey] + if !ok { + return errMissingSyntheticsInfoVersion + } + + versionFloat, ok := version.(float64) + if !ok { + return errInvalidSyntheticsInfoVersion + } + + s.Version = int(versionFloat) + if s.Version != 1 { + return errUnexpectedSyntheticsVersion(s.Version) + } + + infoType, ok := m[typeKey] + if !ok { + return errMissingSyntheticsInfoType + } + + s.Type, ok = infoType.(string) + if !ok { + return errInvalidSyntheticsInfoType + } + + initiator, ok := m[initiatorKey] + if !ok { + return errMissingSyntheticsInfoInitiator + } + + s.Initiator, ok = initiator.(string) + if !ok { + return errInvalidSyntheticsInfoInitiator + } + + attrs, ok := m[attributesKey] + if ok { + attrMap, ok := attrs.(map[string]any) + if !ok { + return errInvalidSyntheticsInfoAttributes + } + for k, v := range attrMap { + val, ok := v.(string) + if !ok { + return errInvalidSyntheticsInfoAttributeVal + } + if s.Attributes == nil { + s.Attributes = map[string]string{k: val} + } else { + s.Attributes[k] = val + } + } + } + + return nil +} diff --git a/v3/internal/cat/synthetics_test.go b/v3/internal/cat/synthetics_test.go index 120d1f53c..b20b01f94 100644 --- a/v3/internal/cat/synthetics_test.go +++ b/v3/internal/cat/synthetics_test.go @@ -5,6 +5,7 @@ package cat import ( "encoding/json" + "fmt" "testing" ) @@ -118,3 +119,129 @@ func TestSyntheticsUnmarshalValid(t *testing.T) { } } } + +func TestSyntheticsInfoUnmarshal(t *testing.T) { + type testCase struct { + name string + json string + syntheticsInfo SyntheticsInfo + expectedError error + } + + testCases := []testCase{ + { + name: "missing type field", + json: `{"version":1,"initiator":"cli"}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errMissingSyntheticsInfoType, + }, + { + name: "invalid type field", + json: `{"version":1,"initiator":"cli","type":1}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errInvalidSyntheticsInfoType, + }, + { + name: "missing initiator field", + json: `{"version":1,"type":"scheduled"}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errMissingSyntheticsInfoInitiator, + }, + { + name: "invalid initiator field", + json: `{"version":1,"initiator":1,"type":"scheduled"}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errInvalidSyntheticsInfoInitiator, + }, + { + name: "missing version field", + json: `{"type":"scheduled"}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errMissingSyntheticsInfoVersion, + }, + { + name: "invalid version field", + json: `{"version":"1","initiator":"cli","type":"scheduled"}`, + syntheticsInfo: SyntheticsInfo{}, + expectedError: errInvalidSyntheticsInfoVersion, + }, + { + name: "valid synthetics info", + json: `{"version":1,"type":"scheduled","initiator":"cli"}`, + syntheticsInfo: SyntheticsInfo{ + Version: 1, + Type: "scheduled", + Initiator: "cli", + }, + expectedError: nil, + }, + { + name: "valid synthetics info with attributes", + json: `{"version":1,"type":"scheduled","initiator":"cli","attributes":{"hi":"hello"}}`, + syntheticsInfo: SyntheticsInfo{ + Version: 1, + Type: "scheduled", + Initiator: "cli", + Attributes: map[string]string{"hi": "hello"}, + }, + expectedError: nil, + }, + { + name: "valid synthetics info with invalid attributes", + json: `{"version":1,"type":"scheduled","initiator":"cli","attributes":{"hi":1}}`, + syntheticsInfo: SyntheticsInfo{ + Version: 1, + Type: "scheduled", + Initiator: "cli", + Attributes: nil, + }, + expectedError: errInvalidSyntheticsInfoAttributeVal, + }, + } + + for _, testCase := range testCases { + syntheticsInfo := SyntheticsInfo{} + err := syntheticsInfo.UnmarshalJSON([]byte(testCase.json)) + if testCase.expectedError == nil { + if err != nil { + recordError(t, testCase.name, fmt.Sprintf("expected synthetics info to unmarshal without error, but got error: %v", err)) + } + + expect := testCase.syntheticsInfo + if expect.Version != syntheticsInfo.Version { + recordError(t, testCase.name, fmt.Sprintf(`expected version "%d", but got "%d"`, expect.Version, syntheticsInfo.Version)) + } + + if expect.Type != syntheticsInfo.Type { + recordError(t, testCase.name, fmt.Sprintf(`expected version "%s", but got "%s"`, expect.Type, syntheticsInfo.Type)) + } + + if expect.Initiator != syntheticsInfo.Initiator { + recordError(t, testCase.name, fmt.Sprintf(`expected version "%s", but got "%s"`, expect.Initiator, syntheticsInfo.Initiator)) + } + + if len(expect.Attributes) != 0 { + if len(syntheticsInfo.Attributes) == 0 { + recordError(t, testCase.name, fmt.Sprintf(`expected attribute array to have %d elements, but it only had %d`, len(expect.Attributes), len(syntheticsInfo.Attributes))) + } + for ek, ev := range expect.Attributes { + v, ok := syntheticsInfo.Attributes[ek] + if !ok { + recordError(t, testCase.name, fmt.Sprintf(`expected attributes to contain key "%s", but it did not`, ek)) + } + if ev != v { + recordError(t, testCase.name, fmt.Sprintf(`expected attributes to contain "%s":"%s", but it contained "%s":"%s"`, ek, ev, ek, v)) + } + } + } + } else { + if err != testCase.expectedError { + recordError(t, testCase.name, fmt.Sprintf(`expected synthetics info to unmarshal with error "%v", but got "%v"`, testCase.expectedError, err)) + } + } + } +} + +func recordError(t *testing.T, test, err string) { + t.Errorf("%s: %s", test, err) +} diff --git a/v3/newrelic/cross_process_http.go b/v3/newrelic/cross_process_http.go index b05d3267f..886875d6b 100644 --- a/v3/newrelic/cross_process_http.go +++ b/v3/newrelic/cross_process_http.go @@ -64,6 +64,11 @@ func metadataToHTTPHeader(metadata crossProcessMetadata) http.Header { if metadata.Synthetics != "" { header.Add(cat.NewRelicSyntheticsName, metadata.Synthetics) + + // This header will only be present when the `X-NewRelic-Synthetics` header is present + if metadata.SyntheticsInfo != "" { + + } } return header diff --git a/v3/newrelic/txn_cross_process.go b/v3/newrelic/txn_cross_process.go index ad14237ef..9ac655459 100644 --- a/v3/newrelic/txn_cross_process.go +++ b/v3/newrelic/txn_cross_process.go @@ -53,19 +53,26 @@ type txnCrossProcess struct { ReferringPathHash string ReferringTxnGUID string Synthetics *cat.SyntheticsHeader + SyntheticsInfo *cat.SyntheticsInfo // The encoded synthetics header received as part of the request headers, if // any. By storing this here, we avoid needing to marshal the invariant // Synthetics struct above each time an external segment is created. SyntheticsHeader string + + // The encoded synthetics info header received as part of the request headers, if + // any. By storing this here, we avoid needing to marshal the invariant + // Synthetics struct above each time an external segment is created. + SyntheticsInfoHeader string } // crossProcessMetadata represents the metadata that must be transmitted with // an external request for CAT to work. type crossProcessMetadata struct { - ID string - TxnData string - Synthetics string + ID string + TxnData string + Synthetics string + SyntheticsInfo string } // Init initialises a txnCrossProcess based on the given application connect @@ -247,6 +254,11 @@ func (txp *txnCrossProcess) handleInboundRequestHeaders(metadata crossProcessMet if err := txp.handleInboundRequestEncodedSynthetics(metadata.Synthetics); err != nil { return err } + if metadata.SyntheticsInfo != "" { + if err := txp.handleInboundRequestEncodedSyntheticsInfo(metadata.SyntheticsInfo); err != nil { + return err + } + } } return nil @@ -338,6 +350,36 @@ func (txp *txnCrossProcess) handleInboundRequestSynthetics(raw []byte) error { return nil } +func (txp *txnCrossProcess) handleInboundRequestEncodedSyntheticsInfo(encoded string) error { + raw, err := deobfuscate(encoded, txp.EncodingKey) + if err != nil { + return err + } + + if err := txp.handleInboundRequestSyntheticsInfo(raw); err != nil { + return err + } + + txp.SyntheticsInfoHeader = encoded + return nil +} + +func (txp *txnCrossProcess) handleInboundRequestSyntheticsInfo(raw []byte) error { + synthetics := &cat.SyntheticsInfo{} + if err := json.Unmarshal(raw, synthetics); err != nil { + return err + } + + // The specced behaviour here if the account isn't trusted is to disable the + // synthetics handling, but not CAT in general, so we won't return an error + // here. + if txp.IsSynthetics() { + txp.SyntheticsInfo = synthetics + } + + return nil +} + func (txp *txnCrossProcess) outboundID() (string, error) { return obfuscate(txp.CrossProcessID, txp.EncodingKey) } diff --git a/v3/newrelic/txn_cross_process_test.go b/v3/newrelic/txn_cross_process_test.go index de07d4890..cceaa1847 100644 --- a/v3/newrelic/txn_cross_process_test.go +++ b/v3/newrelic/txn_cross_process_test.go @@ -85,6 +85,18 @@ func (req *request) withSynthetics(account int, encodingKey string) *request { } req.Header.Add(cat.NewRelicSyntheticsName, string(obfuscated)) + + return req.withSyntheticsInfo("cli", "scheduled", encodingKey) +} + +func (req *request) withSyntheticsInfo(initiator, synthType, encodingKey string) *request { + header := fmt.Sprintf(`{"version":1,"type":"%s","initiator":"%s"}`, synthType, initiator) + obfuscated, err := obfuscate([]byte(header), []byte(encodingKey)) + if err != nil { + panic(err) + } + + req.Header.Add(cat.NewRelicSyntheticsInfo, string(obfuscated)) return req } @@ -168,14 +180,16 @@ func TestTxnCrossProcessInit(t *testing.T) { id := "" txnData := "" synthetics := "" + syntheticsInfo := "" if tc.req != nil { id = tc.req.Header.Get(cat.NewRelicIDName) txnData = tc.req.Header.Get(cat.NewRelicTxnName) synthetics = tc.req.Header.Get(cat.NewRelicSyntheticsName) + syntheticsInfo = tc.req.Header.Get(cat.NewRelicSyntheticsInfo) } actual.Init(tc.enabled, false, tc.reply) - err := actual.handleInboundRequestHeaders(crossProcessMetadata{id, txnData, synthetics}) + err := actual.handleInboundRequestHeaders(crossProcessMetadata{id, txnData, synthetics, syntheticsInfo}) if tc.expectedError == false && err != nil { t.Errorf("%s: unexpected error returned from Init: %v", tc.name, err)