Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CloudWatch: Remove simplejson in favor of 'encoding/json' #51062

Merged
merged 18 commits into from
Jul 8, 2022

Conversation

asimpson
Copy link
Contributor

Which issue(s) this PR fixes:
The simplejson library is no longer recommended by the backend team. Therefore the Cloud datasources team has decided to factor the library out of our plugins in favor of the encoding/json package that's part of the Go standard library.

Fixes #45417

Special notes for your reviewer:
simplejson provides several helper methods to query data and to set defaults if the data returns "empty", e.g. parameters.Get("limit").MustInt64(50) gets the limit property and if it's null sets the value to 50. Using the encoding/json package for this results in a few lines of code instead of one, e.g.

region := "some-default"
if model.Region != "" {
  region = model.Region
}

The second aspect of this is where simplejson will return an Error if the property isn't found, e.g. _, err := queryJson.Get("statistic").String(). Using the encoding/json package for this means we need to modify our default struct definition to be a pointer to a type, e.g. *string instead of string. Here's a playground link showing the difference in Unmarshal.

Verification

  1. Run go test in the cloudwatch package, all test should pass.
  2. Visit any of the premade dashboards for the Cloudwatch plugin, they should work without errors.

Copy link
Contributor

@iwysiu iwysiu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To explain myself for all the newline nits: stylistically it's easier to read if code that works together (ex. creating a variable then setting it) is in the same "paragraph" of code.

The other big note is that we can use the json that already exist to make sure that we marshal to json the same way we used to.

dimensions := model.Get("dimensions").MustMap()
statistic := model.Get("statistic").MustString()
period := int64(model.Get("period").MustInt(0))
usePrefixMatch := model.PrefixMatching
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the rest of the function just use the model.* values instead of making copies?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, for sure.

Region string `json:",omitempty"`
Namespace string `json:",omitempty"`
MetricName string `json:",omitempty"`
Dimensions map[string]interface{} `json:",omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something more precise than interface{} we can parse to? From annotation_query.go it looks like we expect these to be []string

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, for legacy reasons dimensions values can be string | []string. :( This is handled here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😞 unfortunate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I was going to chime in on this, thanks @sunker

@@ -188,7 +201,14 @@ func (e *cloudWatchExecutor) checkHealthLogs(ctx context.Context, pluginCtx back
if err != nil {
return err
}
_, err = e.handleDescribeLogGroups(ctx, logsClient, simplejson.NewFromAny(map[string]interface{}{"limit": "1"}))

defaultLimit := int64(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default values should go at the top of the file with the other consts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, thanks!

"region": model.Get("region").MustString(""),
"queryId": *startQueryOutput.QueryId,
})
requestParams := &LogQueryJson{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to be created as a pointer


if isLogAlertQuery {
return e.executeLogAlertQuery(ctx, req)
}

queryType := model.Get("type").MustString("")
queryType := model.QueryType

var result *backend.QueryDataResponse
switch queryType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can model.QueryType just get plugged in here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, since QueryType will just return "" if it's not set. Which is what MustString is doing.

@@ -406,33 +406,6 @@ func Test_executeStartQuery(t *testing.T) {
}, cli.calls.startQueryWithContext)
})

t.Run("cannot parse limit as float", func(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little worried about why we had this test, because if getting a float limit is possible, we're going to error unmarshaling it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, same.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we ever figure out what was going on with this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was a test as documentation of some behavior or consequence of the behavior of the simplejson library but not sure. I vote for deletion!

}
}

statValue := ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could create the return object here and set values on the return object instead of creating these intermediate values.

@@ -21,15 +22,15 @@ func TestRequestParser(t *testing.T) {
}
oldQuery.RefID = "A"
oldQuery.JSON = []byte(`{
"region": "us-east-1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These shouldn't have to change to parse successfully, I believe unmarshaling is case insensitive

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check!


var query QueryJson
err := json.Unmarshal(fixtureJSON, &query)
query.Period = "900"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: setting period should go after the error check

migrateAliasToDynamicLabel(&query)

matchedJson := []byte(fmt.Sprintf(`{
"Alias": "%s",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it used to marshal to camelCase, we should use the json struct tags to continue marshaling to camelCase.

@asimpson asimpson force-pushed the cloudwatch-remove-simplejson branch from 16357cb to d46a047 Compare June 17, 2022 22:07
@asimpson
Copy link
Contributor Author

The other big note is that we can use the json that already exist to make sure that we marshal to json the same way we used to.

@iwysiu can you explain this more? What already existing json?

Copy link
Contributor

@sunker sunker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great @asimpson! Added a few nits. Will do some more testing when changes are applied.

Region string `json:",omitempty"`
Namespace string `json:",omitempty"`
MetricName string `json:",omitempty"`
Dimensions map[string]interface{} `json:",omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, for legacy reasons dimensions values can be string | []string. :( This is handled here

@@ -324,18 +344,19 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa
frontend, but because alerts are executed on the backend the logic needs to be reimplemented here.
*/
q := req.Queries[0]
model, err := simplejson.NewJson(q.JSON)
var model DataQueryJson
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like DataQuery is only used in annotation queries. I think it would be good to highlight the difference between the three different query types in a similar way to how it's done in the frontend. In the executeAnnotationQuery you can unmarshal json into an AnnotationDataQuery struct. In executeLogActions, you can unmarshal into a LogsDataQuery and in executeTimeSeriesQuery you can unmarshal into a MetricsDataQuery.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do use model here to check the QueryType. It is strange and inconsistent in that executeAnnotationQuery is the only "execute" function we pass a model to. log_actions unmarshal's from req.Queries and timeSeriesQuery doesn't do either, it deals with the data on req directly. I'd propose we merge this as is and then create a new issue around unifying how the various query types handle data. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and executeLogAlertQuery is somehow in cloudwatch.go 🤷‍♂️ Which seems odd as well...

if err != nil {
return nil, err
}
_, fromAlert := req.Headers["FromAlert"]
isLogAlertQuery := fromAlert && model.Get("queryMode").MustString("") == "Logs"
isLogAlertQuery := fromAlert && model.QueryMode == "Logs"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to introduce string constants for the query modes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

@@ -390,7 +415,7 @@ func (e *cloudWatchExecutor) executeLogAlertQuery(ctx context.Context, req *back

var frames []*data.Frame

statsGroups := model.Get("statsGroups").MustStringArray()
statsGroups := model.StatsGroups
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to copy this value - should be fine to use it directly in the next lines.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice call, thanks!

"golang.org/x/sync/errgroup"
)

const (
LimitExceededException = "LimitExceededException"
defaultLimit = 10
defaultLimit = int64(10)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if unexported constants should start with a capital letter or not (think it's lower case @iwysiu ?). Just as long as this constant and LimitExceededException use the same convention I'm happy. :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, unexported values should be lower case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! 😎

pkg/tsdb/cloudwatch/cloudwatch.go Outdated Show resolved Hide resolved
pkg/tsdb/cloudwatch/cloudwatch.go Outdated Show resolved Hide resolved
pkg/tsdb/cloudwatch/log_actions.go Show resolved Hide resolved
pkg/tsdb/cloudwatch/log_actions.go Outdated Show resolved Hide resolved
TimezoneUTCOffset string `json:",omitempty"`
QueryType string `json:",omitempty"`
Hide *bool `json:",omitempty"`
Alias *string `json:",omitempty"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the omitempty like in my other comment:
A quick look at the tests suggests that we need omitempty to pass some tests and not to pass other tests (for example Alias).

To "dumbly" pass the tests, we can add omitempty only on fields where it is necessary to pass the tests (it should be where there are no keys in the expected Json).

We ought to be able to make test assertions on the struct itself, not the encoded json. This will avoid the issue with the upper case in the expected json tags. But making these tests pass without any changes should be a good first step. So I think struct assertions would be better for a different commit. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, however for this specific PR I wanted to focus strictly on removing simplejson. I think this would be a great future issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think my comment was very clear, sorry about that.

To "dumbly" pass the tests, we can add omitempty only on fields where it is necessary to pass the tests (it should be where there are no keys in the expected Json).

I think the above is where I was getting at: only adding omitempty where strictly necessary. This is (I think) only relevant in the test Test_Test_migrateLegacyQuery (yeah the title seems to have a typo...)

To clarify on the struct point:
Almost every test now has superfluous decoding from raw json into a struct. We should just build the struct directly as it very closely resembles the map[string]interface{} from the original code.

@fridgepoet
Copy link
Member

This looks great!

@grafanabot grafanabot removed this from the 9.0.1 milestone Jun 21, 2022
@grafanabot
Copy link
Contributor

This pull request was removed from the 9.0.1 milestone because 9.0.1 is currently being released.

@iwysiu
Copy link
Contributor

iwysiu commented Jun 21, 2022

The other big note is that we can use the json that already exist to make sure that we marshal to json the same way we used to.

@iwysiu can you explain this more? What already existing json?

What I was trying to say there was that we shouldn't change the json in the marshalling/unmarshalling tests, but I managed to say that in the most confusing possible way

@grafanabot
Copy link
Contributor

@asimpson asimpson added this to the 9.0.3 milestone Jul 7, 2022
@grafanabot
Copy link
Contributor

@fridgepoet fridgepoet self-requested a review July 8, 2022 11:50
Copy link
Member

@fridgepoet fridgepoet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I included a comment about using the structs, but am happy to do that refactor in a different PR after this is merged.

Will let the others add another approval

Copy link
Contributor

@iwysiu iwysiu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! A last few comments


var period int64
if model.Period != "" {
p, err := strconv.Atoi(model.Period)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: you could use strconv.ParseInt to convert directly to a int64

region := model.Get("region").MustString(defaultRegion)
if region == defaultRegion {
region := model.Region
if model.Region != "" || region == defaultRegion {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be region == "" ?

)

var logGroupDefaultLimit = int64(50)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value should be the const block and not edited in the functions.

logGroupNamePrefix := parameters.Get("logGroupNamePrefix").MustString("")
logsClient cloudwatchlogsiface.CloudWatchLogsAPI, parameters LogQueryJson) (*data.Frame, error) {
if parameters.Limit != nil && *parameters.Limit != 0 {
logGroupDefaultLimit = *parameters.Limit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate logGroupLimit variable should be made here instead of changing the global

@@ -406,33 +406,6 @@ func Test_executeStartQuery(t *testing.T) {
}, cli.calls.startQueryWithContext)
})

t.Run("cannot parse limit as float", func(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we ever figure out what was going on with this?


return &cloudWatchQuery, nil

/* return &cloudWatchQuery{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this commented out block

@@ -257,9 +301,9 @@ func getRetainedPeriods(timeSince time.Duration) []int {
}
}

func parseDimensions(model *simplejson.Json) (map[string][]string, error) {
func parseDimensions(model QueryJson) (map[string][]string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could be changed to just take the model.Dimensions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iwysiu re: #51062 (comment) we don't need to test for float on Limit since we now have a type (LogQueryJson) that defines the data.

@asimpson asimpson merged commit 05cdef5 into main Jul 8, 2022
@asimpson asimpson deleted the cloudwatch-remove-simplejson branch July 8, 2022 19:39
@grafanabot
Copy link
Contributor

The backport to v9.0.x failed:

The process '/usr/bin/git' failed with exit code 1

To backport manually, run these commands in your terminal:

# Fetch latest updates from GitHub
git fetch
# Create a new branch
git switch --create backport-51062-to-v9.0.x origin/v9.0.x
# Cherry-pick the merged commit of this pull request and resolve the conflicts
git cherry-pick -x 05cdef50040c06bf7e876a3b99a997ea4e826cc1
# Push it to GitHub
git push --set-upstream origin backport-51062-to-v9.0.x
git switch main
# Remove the local backport branch
git branch -D backport-51062-to-v9.0.x

Then, create a pull request where the base branch is v9.0.x and the compare/head branch is backport-51062-to-v9.0.x.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

CloudWatch: Deserialize json to typed struct instead of using simplejson
5 participants