Skip to content

Commit

Permalink
Merge pull request #837 from nr-swilloughby/synthetics_v1
Browse files Browse the repository at this point in the history
Synthetics Info Extension
  • Loading branch information
nr-swilloughby authored Dec 14, 2023
2 parents d655461 + 37461f9 commit 321732c
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 22 deletions.
1 change: 1 addition & 0 deletions v3/internal/cat/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ const (
NewRelicTxnName = "X-Newrelic-Transaction"
NewRelicAppDataName = "X-Newrelic-App-Data"
NewRelicSyntheticsName = "X-Newrelic-Synthetics"
NewRelicSyntheticsInfo = "X-Newrelic-Synthetics-Info"
)
106 changes: 100 additions & 6 deletions v3/internal/cat/synthetics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
127 changes: 127 additions & 0 deletions v3/internal/cat/synthetics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cat

import (
"encoding/json"
"fmt"
"testing"
)

Expand Down Expand Up @@ -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)
}
12 changes: 9 additions & 3 deletions v3/newrelic/cross_process_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ func httpHeaderToMetadata(header http.Header) crossProcessMetadata {
}

return crossProcessMetadata{
ID: header.Get(cat.NewRelicIDName),
TxnData: header.Get(cat.NewRelicTxnName),
Synthetics: header.Get(cat.NewRelicSyntheticsName),
ID: header.Get(cat.NewRelicIDName),
TxnData: header.Get(cat.NewRelicTxnName),
Synthetics: header.Get(cat.NewRelicSyntheticsName),
SyntheticsInfo: header.Get(cat.NewRelicSyntheticsInfo),
}
}

Expand All @@ -64,6 +65,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 != "" {
header.Add(cat.NewRelicSyntheticsInfo, metadata.SyntheticsInfo)
}
}

return header
Expand Down
51 changes: 47 additions & 4 deletions v3/newrelic/txn_cross_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -88,6 +95,7 @@ func (txp *txnCrossProcess) CreateCrossProcessMetadata(txnName, appName string)
// outbound request headers.
if txp.IsSynthetics() {
metadata.Synthetics = txp.SyntheticsHeader
metadata.SyntheticsInfo = txp.SyntheticsInfoHeader
}

if txp.Enabled {
Expand Down Expand Up @@ -142,7 +150,7 @@ func (txp *txnCrossProcess) IsSynthetics() bool {
// pointer should be sufficient to determine if this is a synthetics
// transaction. Nevertheless, it's convenient to have the Type field be
// non-zero if any CAT behaviour has occurred.
return 0 != (txp.Type&txnCrossProcessSynthetics) && nil != txp.Synthetics
return (txp.Type&txnCrossProcessSynthetics) != 0 && txp.Synthetics != nil
}

// ParseAppData decodes the given appData value.
Expand Down Expand Up @@ -247,6 +255,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
Expand Down Expand Up @@ -338,6 +351,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)
}
Expand Down
Loading

0 comments on commit 321732c

Please sign in to comment.