Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/services/newrelic.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,22 @@ 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
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
annotations:
notifications.argoproj.io/subscribe.<trigger-name>.newrelic: <app-id>
notifications.argoproj.io/subscribe.<trigger-name>.newrelic: <app-id> || <app-name>
```

**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.
Expand Down
61 changes: 58 additions & 3 deletions pkg/services/newrelic.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
texttemplate "text/template"

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

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

I'd use the url library to construct this, to avoid appName potentially injecting arbitrary stuff

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 == "" {
Expand Down Expand Up @@ -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)
Comment on lines +185 to +187
Copy link
Member

Choose a reason for hiding this comment

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

Instead of reusing Recipient, could you use a new RecipientName field? Clarifies the UX and avoids any weirdness around, for example, a name that happens to also be a number.

Copy link
Author

Choose a reason for hiding this comment

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

Thank you for taking a look at this. I first interpreted your comment as changing the Destination struct but I'm not actually sure where you mean without making this change of wider significance.

I had a look at the other services to see how they had handled service-specific changes in recipient fields:

// It's a channel
if strings.HasPrefix(dest.Recipient, "#") || strings.HasPrefix(dest.Recipient, "@") {
message.Channel = dest.Recipient
} else {
message.RoomID = dest.Recipient

if strings.HasPrefix(dest.Recipient, "-") {
chatID, err := strconv.ParseInt(dest.Recipient, 10, 64)
if err != nil {
return err
}
// Init message with ParseMode is 'Markdown'
msg := tgbotapi.NewMessage(chatID, notification.Message)
msg.ParseMode = "Markdown"
_, err = bot.Send(msg)
if err != nil {
return err
}
} else {
// Init message with ParseMode is 'Markdown'
msg := tgbotapi.NewMessageToChannel("@"+dest.Recipient, notification.Message)
msg.ParseMode = "Markdown"


Could we do something similar with name: 12345678 and id: 12345678 and default to the latter given no prefix for backwards compatibility? That would leave the Destination struct alone and address the issue of New Relic APM names that happen to be numbers.

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)
Copy link
Member

Choose a reason for hiding this comment

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

+1, use url package to construct URLs

req, err := http.NewRequest(http.MethodPost, markerApi, bytes.NewBuffer(jsonValue))
if err != nil {
log.Errorf("Failed to create deployment marker request: %s", err)
Expand Down
119 changes: 119 additions & 0 deletions pkg/services/newrelic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]"
}
}`, 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: "[email protected]",
},
}, 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",
Expand Down Expand Up @@ -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)
})
}