From d8832841101412f73e8e3f6af8511b7908ba1252 Mon Sep 17 00:00:00 2001 From: 3bbbeau <0x3bb@3bb.io> Date: Wed, 5 Mar 2025 11:34:41 +0000 Subject: [PATCH 1/2] feat(newrelic): APM application id can be looked up by name This is intended to be a quality of life addition to allow `ApplicationSet` resources to minimize boilerplate when dealing with NewRelic APM resources across multiple environments. - An application programmatically [uses the NR SDK to report to NewRelic](https://github.com/newrelic/go-agent/blob/master/v3/examples/server/main.go#L267-L274) - NewRelic APM Application ID has to then be retrieved following the first run of an application from the NR Dashboard and added to an ArgoCD `Application` via the annotation, across multiple environments. Using ArgoCD `ApplicationSet` resources, the annotation can be either provided via app_id, or by name, e.g. `MyApp-{{ .values.env }}` If `dest.Recipient` can be parsed to an int, then app_id is provided and logic remains as before. If `dest.Recipient` cannot be parsed to an integer, then it was passed by name and we call `/v2/applications.json` to query using `filter[name]` If `/v2/applications.json` returns multiple application IDs then an error is returned, as we can't determine which app_id to use to place the deployment marker. Signed-off-by: 3bbbeau <0x3bb@3bb.io> --- pkg/services/newrelic.go | 61 ++++++++++++++++- pkg/services/newrelic_test.go | 119 ++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/pkg/services/newrelic.go b/pkg/services/newrelic.go index f0717be1..d7b59464 100644 --- a/pkg/services/newrelic.go +++ b/pkg/services/newrelic.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" texttemplate "text/template" @@ -27,8 +28,10 @@ type NewrelicNotification struct { } var ( - ErrMissingConfig = errors.New("config is missing") - ErrMissingApiKey = errors.New("apiKey is missing") + ErrMissingConfig = errors.New("config is missing") + ErrMissingApiKey = errors.New("apiKey is missing") + ErrAppIdMultipleMatches = errors.New("multiple matches found for application name") + ErrAppIdNoMatches = errors.New("no matches found for application name") ) func (n *NewrelicNotification) GetTemplater(name string, f texttemplate.FuncMap) (Templater, error) { @@ -109,6 +112,43 @@ type newrelicService struct { type newrelicDeploymentMarkerRequest struct { Deployment NewrelicNotification `json:"deployment"` } +type newrelicApplicationsResponse struct { + Applications []struct { + ID json.Number `json:"id"` + } `json:"applications"` +} + +func (s newrelicService) getApplicationId(client *http.Client, appName string) (string, error) { + applicationsApi := fmt.Sprintf("%s/v2/applications.json?filter[name]=%s", s.opts.ApiURL, appName) + req, err := http.NewRequest(http.MethodGet, applicationsApi, nil) + if err != nil { + return "", fmt.Errorf("Failed to create filtered application request: %s", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", s.opts.ApiKey) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var data newrelicApplicationsResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", fmt.Errorf("Failed to decode applications response: %s", err) + } + + if len(data.Applications) == 0 { + return "", ErrAppIdNoMatches + } + + if len(data.Applications) > 1 { + return "", ErrAppIdMultipleMatches + } + + return data.Applications[0].ID.String(), nil +} func (s newrelicService) Send(notification Notification, dest Destination) error { if s.opts.ApiKey == "" { @@ -142,7 +182,22 @@ func (s newrelicService) Send(notification Notification, dest Destination) error return err } - markerApi := fmt.Sprintf(s.opts.ApiURL+"/v2/applications/%s/deployments.json", dest.Recipient) + var appId = dest.Recipient + if dest.Recipient != "" { + _, err := strconv.Atoi(dest.Recipient) + if err != nil { + log.Debugf( + "Recipient was provided by application name. Looking up the application id for %s", + dest.Recipient, + ) + appId, err = s.getApplicationId(client, dest.Recipient) + if err != nil { + log.Errorf("Failed to lookup application %s by name: %s", dest.Recipient, err) + return err + } + } + } + markerApi := fmt.Sprintf(s.opts.ApiURL+"/v2/applications/%s/deployments.json", appId) req, err := http.NewRequest(http.MethodPost, markerApi, bytes.NewBuffer(jsonValue)) if err != nil { log.Errorf("Failed to create deployment marker request: %s", err) diff --git a/pkg/services/newrelic_test.go b/pkg/services/newrelic_test.go index d8978865..e406bc3e 100644 --- a/pkg/services/newrelic_test.go +++ b/pkg/services/newrelic_test.go @@ -141,6 +141,59 @@ func TestSend_Newrelic(t *testing.T) { } }) + t.Run("recipient is application name", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/applications.json" { + _, err := w.Write([]byte(`{ + "applications": [ + {"id": "123456789"} + ] + }`)) + if !assert.NoError(t, err) { + t.FailNow() + } + return + } + + b, err := io.ReadAll(r.Body) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Equal(t, "/v2/applications/123456789/deployments.json", r.URL.Path) + assert.Equal(t, []string{"application/json"}, r.Header["Content-Type"]) + assert.Equal(t, []string{"NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ"}, r.Header["X-Api-Key"]) + + assert.JSONEq(t, `{ + "deployment": { + "revision": "2027ed5", + "description": "message", + "user": "datanerd@example.com" + } + }`, string(b)) + })) + defer ts.Close() + + service := NewNewrelicService(NewrelicOptions{ + ApiKey: "NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ", + ApiURL: ts.URL, + }) + err := service.Send(Notification{ + Message: "message", + Newrelic: &NewrelicNotification{ + Revision: "2027ed5", + User: "datanerd@example.com", + }, + }, Destination{ + Service: "newrelic", + Recipient: "myapp", + }) + + if !assert.NoError(t, err) { + t.FailNow() + } + }) + t.Run("missing config", func(t *testing.T) { service := NewNewrelicService(NewrelicOptions{ ApiKey: "NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ", @@ -171,3 +224,69 @@ func TestSend_Newrelic(t *testing.T) { } }) } + +func TestGetApplicationId(t *testing.T) { + t.Run("successful lookup by application name", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v2/applications.json", r.URL.Path) + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "myapp", r.URL.Query().Get("filter[name]")) + assert.Equal(t, []string{"application/json"}, r.Header["Content-Type"]) + assert.Equal(t, []string{"NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ"}, r.Header["X-Api-Key"]) + + _, err := w.Write([]byte(`{ + "applications": [ + {"id": "123456789"} + ] + }`)) + if !assert.NoError(t, err) { + t.FailNow() + } + })) + defer ts.Close() + service := NewNewrelicService(NewrelicOptions{ + ApiKey: "NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ", + ApiURL: ts.URL, + }).(*newrelicService) + appId, err := service.getApplicationId(http.DefaultClient, "myapp") + assert.NoError(t, err) + assert.Equal(t, "123456789", appId) + }) + + t.Run("application not found", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`{"applications": []}`)) + if !assert.NoError(t, err) { + t.FailNow() + } + })) + defer ts.Close() + service := NewNewrelicService(NewrelicOptions{ + ApiKey: "NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ", + ApiURL: ts.URL, + }).(*newrelicService) + _, err := service.getApplicationId(http.DefaultClient, "myapp") + assert.Equal(t, ErrAppIdNoMatches, err) + }) + + t.Run("multiple matches for application name", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`{ + "applications": [ + {"id": "123456789"}, + {"id": "987654321"} + ] + }`)) + if !assert.NoError(t, err) { + t.FailNow() + } + })) + defer ts.Close() + service := NewNewrelicService(NewrelicOptions{ + ApiKey: "NRAK-5F2FIVA5UTA4FFDD11XCXVA7WPJ", + ApiURL: ts.URL, + }).(*newrelicService) + _, err := service.getApplicationId(http.DefaultClient, "myapp") + assert.Equal(t, ErrAppIdMultipleMatches, err) + }) +} From 2d77a476bf2a447304327bdd8477ed21b7739fe5 Mon Sep 17 00:00:00 2001 From: 3bbbeau <0x3bb@3bb.io> Date: Wed, 5 Mar 2025 11:49:20 +0000 Subject: [PATCH 2/2] docs(newrelic): applications can be referenced by name Signed-off-by: 3bbbeau <0x3bb@3bb.io> --- docs/services/newrelic.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/services/newrelic.md b/docs/services/newrelic.md index b0c7e340..3a9d4983 100644 --- a/docs/services/newrelic.md +++ b/docs/services/newrelic.md @@ -30,7 +30,7 @@ stringData: newrelic-apiKey: apiKey ``` -3. Copy [Application ID](https://docs.newrelic.com/docs/apis/rest-api-v2/get-started/get-app-other-ids-new-relic-one/#apm) +3. Copy [Application ID](https://docs.newrelic.com/docs/apis/rest-api-v2/get-started/get-app-other-ids-new-relic-one/#apm) or [Application Name](https://docs.newrelic.com/docs/apm/agents/manage-apm-agents/app-naming/name-your-application/#app-alias) 4. Create subscription for your NewRelic integration ```yaml @@ -38,9 +38,14 @@ apiVersion: argoproj.io/v1alpha1 kind: Application metadata: annotations: - notifications.argoproj.io/subscribe..newrelic: + notifications.argoproj.io/subscribe..newrelic: || ``` +**Notes** + +- If you use an application name, `app_id` will be looked up by name. +- If multiple applications matching the application name are returned by NewRelic, then no deployment marker will be created. + ## Templates * `description` - __optional__, high-level description of this deployment, visible in the [Summary](https://docs.newrelic.com/docs/apm/applications-menu/monitoring/apm-overview-page) page and on the [Deployments](https://docs.newrelic.com/docs/apm/applications-menu/events/deployments-page) page when you select an individual deployment.