diff --git a/docs/components/NewRelic.mdx b/docs/components/NewRelic.mdx new file mode 100644 index 0000000000..28a77cf6d5 --- /dev/null +++ b/docs/components/NewRelic.mdx @@ -0,0 +1,166 @@ +--- +title: "New Relic" +--- + +React to alerts and query telemetry data from New Relic + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Triggers + + + + + +## Actions + + + + + + +## Instructions + +### Getting your credentials + +1. **Account ID**: Click your name in the bottom-left corner of New Relic. Your Account ID is displayed under the account name. + +2. **User API Key**: Go to the **API Keys** page. Click **Create a key**. Select key type **User**. Give it a name (e.g. "SuperPlane") and click **Create a key**. This key is used for NerdGraph/NRQL queries — no additional permissions are needed. + +3. **License Key**: On the same **API Keys** page, find the key with type **Ingest - License** and copy it. This key is used for sending metrics. If no license key exists, click **Create a key** and select **Ingest - License**. + +4. **Region**: Choose **US** if your New Relic URL is `one.newrelic.com`, or **EU** if it is `one.eu.newrelic.com`. + +### Webhook Setup + +SuperPlane automatically creates a Webhook Notification Channel in your New Relic account when you add the **On Issue** trigger to your canvas. Just attach it to your alert workflow in New Relic to start receiving alerts. + + + +## On Issue + +The On Issue trigger starts a workflow execution when a New Relic alert issue is received via webhook. + +### What this trigger does + +- Receives New Relic webhook payloads for alert issues +- Filters by issue state (CREATED, ACTIVATED, ACKNOWLEDGED, CLOSED) +- Optionally filters by priority (CRITICAL, HIGH, MEDIUM, LOW) +- Emits matching issues as `newrelic.issue` events + +### Configuration + +- **Statuses**: Required list of issue states to listen for +- **Priorities**: Optional priority filter + +### Webhook Setup + +SuperPlane automatically creates a Webhook Notification Channel in your New Relic account. Just attach it to your alert workflow to start receiving alerts. + +### Example Data + +```json +{ + "data": { + "accountId": 1234567, + "conditionName": "CPU usage \u003e 90%", + "createdAt": 1704067200000, + "issueId": "MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "issueUrl": "https://one.newrelic.com/alerts-ai/issues/MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "policyName": "Production Infrastructure", + "priority": "CRITICAL", + "sources": [ + "newrelic" + ], + "state": "ACTIVATED", + "title": "High CPU usage on production server", + "updatedAt": 1704067260000 + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "newrelic.issue" +} +``` + + + +## Report Metric + +The Report Metric component sends custom metric data to New Relic's Metric API. + +### Use Cases + +- **Deployment metrics**: Track deployment frequency and duration +- **Business metrics**: Report custom KPIs like revenue, signups, or conversion rates +- **Pipeline metrics**: Measure workflow execution times and success rates + +### Configuration + +- `metricName`: The name of the metric (e.g., custom.deployment.count) +- `metricType`: The type of metric (gauge, count, or summary) +- `value`: The numeric value for the metric +- `attributes`: Optional key-value labels for the metric +- `timestamp`: Optional Unix epoch milliseconds (defaults to now) + +### Outputs + +The component emits a metric confirmation containing: +- `metricName`: The name of the reported metric +- `metricType`: The type of the metric +- `value`: The reported value +- `timestamp`: The timestamp used + +### Example Output + +```json +{ + "data": { + "metricName": "custom.deployment.count", + "metricType": "count", + "timestamp": 1704067200000, + "value": 1 + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "newrelic.metric" +} +``` + + + +## Run NRQL Query + +The Run NRQL Query component executes a NRQL query against New Relic data via the NerdGraph API. + +### Use Cases + +- **Health checks**: Query application error rates or response times before deployments +- **Capacity planning**: Check resource utilization metrics +- **Incident investigation**: Query telemetry data during incident workflows + +### Configuration + +- `query`: The NRQL query string to execute +- `timeout`: Optional query timeout in seconds (default: 30) + +### Outputs + +The component emits query results containing: +- `query`: The executed NRQL query +- `results`: Array of result rows returned by the query + +### Example Output + +```json +{ + "data": { + "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago", + "results": [ + { + "count": 42567 + } + ] + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "newrelic.nrqlResult" +} +``` + diff --git a/pkg/integrations/newrelic/client.go b/pkg/integrations/newrelic/client.go new file mode 100644 index 0000000000..617e2ce206 --- /dev/null +++ b/pkg/integrations/newrelic/client.go @@ -0,0 +1,441 @@ +package newrelic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/superplanehq/superplane/pkg/core" +) + +const MaxResponseSize = 1 * 1024 * 1024 // 1MB + +// APIError represents an HTTP error response from New Relic. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("request got %d code: %s", e.StatusCode, e.Body) +} + +type Client struct { + AccountID string + UserAPIKey string + LicenseKey string + NerdGraphURL string + MetricAPIURL string + http core.HTTPContext +} + +func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + accountID, err := ctx.GetConfig("accountId") + if err != nil { + return nil, fmt.Errorf("error getting accountId: %v", err) + } + + region, err := ctx.GetConfig("region") + if err != nil { + return nil, fmt.Errorf("error getting region: %v", err) + } + + userAPIKey, err := ctx.GetConfig("userApiKey") + if err != nil { + return nil, fmt.Errorf("error getting userApiKey: %v", err) + } + + licenseKey, err := ctx.GetConfig("licenseKey") + if err != nil { + return nil, fmt.Errorf("error getting licenseKey: %v", err) + } + + accountIDStr := string(accountID) + if !accountIDRegexp.MatchString(accountIDStr) { + return nil, fmt.Errorf("accountId must be numeric, got %q", accountIDStr) + } + + nerdGraphURL, metricAPIURL := urlsForRegion(string(region)) + + return &Client{ + AccountID: accountIDStr, + UserAPIKey: string(userAPIKey), + LicenseKey: string(licenseKey), + NerdGraphURL: nerdGraphURL, + MetricAPIURL: metricAPIURL, + http: http, + }, nil +} + +func urlsForRegion(region string) (string, string) { + if region == "EU" { + return "https://api.eu.newrelic.com/graphql", "https://metric-api.eu.newrelic.com/metric/v1" + } + + return "https://api.newrelic.com/graphql", "https://metric-api.newrelic.com/metric/v1" +} + +// ValidateCredentials verifies that the User API Key is valid +// by running a simple NerdGraph query. +func (c *Client) ValidateCredentials(ctx context.Context) error { + query := `{ "query": "{ actor { user { name } } }" }` + body, err := c.nerdGraphRequest(ctx, []byte(query)) + if err != nil { + return err + } + + var res struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(body, &res); err != nil { + return fmt.Errorf("error parsing validation response: %v", err) + } + + if len(res.Errors) > 0 { + return fmt.Errorf("GraphQL error: %s", res.Errors[0].Message) + } + + return nil +} + +// NerdGraphQuery executes a NerdGraph (GraphQL) query and returns the raw response. +func (c *Client) NerdGraphQuery(ctx context.Context, query string) ([]byte, error) { + payload := map[string]string{"query": query} + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("error marshaling query: %v", err) + } + + return c.nerdGraphRequest(ctx, body) +} + +// ReportMetric sends metric data to the New Relic Metric API. +func (c *Client) ReportMetric(ctx context.Context, payload []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.MetricAPIURL, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Api-Key", c.LicenseKey) + + return c.execRequest(req) +} + +// CreateNotificationDestination creates a webhook destination in New Relic via NerdGraph. +func (c *Client) CreateNotificationDestination(ctx context.Context, webhookURL string, secret string) (string, error) { + name := fmt.Sprintf("SuperPlane-%d", time.Now().UnixMilli()) + authBlock := "" + if secret != "" { + authBlock = fmt.Sprintf(`, auth: {type: TOKEN, token: {prefix: "Bearer", token: %s}}`, + quoteGraphQL(secret)) + } + query := fmt.Sprintf(`mutation { + aiNotificationsCreateDestination(accountId: %s, destination: { + name: %s, + type: WEBHOOK, + properties: [{key: "url", value: %s}]%s + }) { + destination { id } + error { + ... on AiNotificationsResponseError { description } + ... on AiNotificationsDataValidationError { details } + ... on AiNotificationsSuggestionError { description details } + } + } + }`, c.AccountID, quoteGraphQL(name), quoteGraphQL(webhookURL), authBlock) + + body, err := c.NerdGraphQuery(ctx, query) + if err != nil { + return "", fmt.Errorf("failed to create notification destination: %w", err) + } + + var res struct { + Data struct { + Result struct { + Destination struct { + ID string `json:"id"` + } `json:"destination"` + Error *struct { + Description string `json:"description"` + Details string `json:"details"` + } `json:"error"` + } `json:"aiNotificationsCreateDestination"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(body, &res); err != nil { + return "", fmt.Errorf("failed to parse destination response: %w", err) + } + + if len(res.Errors) > 0 { + return "", fmt.Errorf("GraphQL error creating destination: %s", res.Errors[0].Message) + } + + if res.Data.Result.Error != nil { + errMsg := res.Data.Result.Error.Description + if errMsg == "" { + errMsg = res.Data.Result.Error.Details + } + if errMsg == "" { + errMsg = "unknown error" + } + return "", fmt.Errorf("failed to create destination: %s", errMsg) + } + + if res.Data.Result.Destination.ID == "" { + return "", fmt.Errorf("destination created but no ID returned") + } + + return res.Data.Result.Destination.ID, nil +} + +// CreateNotificationChannel creates a webhook notification channel in New Relic via NerdGraph. +func (c *Client) CreateNotificationChannel(ctx context.Context, destinationID string, payloadTemplate string) (string, error) { + name := fmt.Sprintf("SuperPlane-%d", time.Now().UnixMilli()) + query := fmt.Sprintf(`mutation { + aiNotificationsCreateChannel(accountId: %s, channel: { + name: %s, + type: WEBHOOK, + product: IINT, + destinationId: %s, + properties: [{key: "payload", value: %s}] + }) { + channel { id } + error { + ... on AiNotificationsResponseError { description } + ... on AiNotificationsDataValidationError { details } + ... on AiNotificationsSuggestionError { description details } + } + } + }`, c.AccountID, quoteGraphQL(name), quoteGraphQL(destinationID), quoteGraphQL(payloadTemplate)) + + body, err := c.NerdGraphQuery(ctx, query) + if err != nil { + return "", fmt.Errorf("failed to create notification channel: %w", err) + } + + var res struct { + Data struct { + Result struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + Error *struct { + Description string `json:"description"` + Details string `json:"details"` + } `json:"error"` + } `json:"aiNotificationsCreateChannel"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(body, &res); err != nil { + return "", fmt.Errorf("failed to parse channel response: %w", err) + } + + if len(res.Errors) > 0 { + return "", fmt.Errorf("GraphQL error creating channel: %s", res.Errors[0].Message) + } + + if res.Data.Result.Error != nil { + errMsg := res.Data.Result.Error.Description + if errMsg == "" { + errMsg = res.Data.Result.Error.Details + } + if errMsg == "" { + errMsg = "unknown error" + } + return "", fmt.Errorf("failed to create channel: %s", errMsg) + } + + if res.Data.Result.Channel.ID == "" { + return "", fmt.Errorf("channel created but no ID returned") + } + + return res.Data.Result.Channel.ID, nil +} + +// DeleteNotificationChannel deletes a notification channel in New Relic via NerdGraph. +func (c *Client) DeleteNotificationChannel(ctx context.Context, channelID string) error { + query := fmt.Sprintf(`mutation { + aiNotificationsDeleteChannel(accountId: %s, channelId: %s) { + ids + error { details } + } + }`, c.AccountID, quoteGraphQL(channelID)) + + body, err := c.NerdGraphQuery(ctx, query) + if err != nil { + return fmt.Errorf("failed to delete notification channel: %w", err) + } + + var res struct { + Data struct { + Result struct { + IDs []string `json:"ids"` + Error *struct { + Details string `json:"details"` + } `json:"error"` + } `json:"aiNotificationsDeleteChannel"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(body, &res); err != nil { + return fmt.Errorf("failed to parse delete channel response: %w", err) + } + + if len(res.Errors) > 0 { + return fmt.Errorf("GraphQL error deleting channel: %s", res.Errors[0].Message) + } + + if res.Data.Result.Error != nil && res.Data.Result.Error.Details != "" { + return fmt.Errorf("failed to delete channel: %s", res.Data.Result.Error.Details) + } + + return nil +} + +// DeleteNotificationDestination deletes a notification destination in New Relic via NerdGraph. +func (c *Client) DeleteNotificationDestination(ctx context.Context, destinationID string) error { + query := fmt.Sprintf(`mutation { + aiNotificationsDeleteDestination(accountId: %s, destinationId: %s) { + ids + error { details } + } + }`, c.AccountID, quoteGraphQL(destinationID)) + + body, err := c.NerdGraphQuery(ctx, query) + if err != nil { + return fmt.Errorf("failed to delete notification destination: %w", err) + } + + var res struct { + Data struct { + Result struct { + IDs []string `json:"ids"` + Error *struct { + Details string `json:"details"` + } `json:"error"` + } `json:"aiNotificationsDeleteDestination"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + + if err := json.Unmarshal(body, &res); err != nil { + return fmt.Errorf("failed to parse delete destination response: %w", err) + } + + if len(res.Errors) > 0 { + return fmt.Errorf("GraphQL error deleting destination: %s", res.Errors[0].Message) + } + + if res.Data.Result.Error != nil && res.Data.Result.Error.Details != "" { + return fmt.Errorf("failed to delete destination: %s", res.Data.Result.Error.Details) + } + + return nil +} + +type NerdGraphNRQLResponse struct { + Data struct { + Actor struct { + Account struct { + NRQL struct { + Results []any `json:"results"` + } `json:"nrql"` + } `json:"account"` + } `json:"actor"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// RunNRQLQuery executes a NRQL query via NerdGraph and returns the result rows. +func (c *Client) RunNRQLQuery(ctx context.Context, query string, timeout int) ([]any, error) { + graphQLQuery := fmt.Sprintf( + `{ actor { account(id: %s) { nrql(query: %s, timeout: %d) { results } } } }`, + c.AccountID, + quoteGraphQL(query), + timeout, + ) + body, err := c.NerdGraphQuery(ctx, graphQLQuery) + if err != nil { + return nil, err + } + var response NerdGraphNRQLResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + if len(response.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", response.Errors[0].Message) + } + results := response.Data.Actor.Account.NRQL.Results + if results == nil { + results = []any{} + } + return results, nil +} + +func quoteGraphQL(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func (c *Client) nerdGraphRequest(ctx context.Context, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.NerdGraphURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Api-Key", c.UserAPIKey) + + return c.execRequest(req) +} + +func (c *Client) execRequest(req *http.Request) ([]byte, error) { + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer res.Body.Close() + + limitedReader := io.LimitReader(res.Body, MaxResponseSize+1) + responseBody, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("error reading body: %v", err) + } + + if len(responseBody) > MaxResponseSize { + return nil, fmt.Errorf("response too large: exceeds maximum size of %d bytes", MaxResponseSize) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + errBody := string(responseBody) + if len(errBody) > 256 { + errBody = errBody[:256] + "... (truncated)" + } + return nil, &APIError{StatusCode: res.StatusCode, Body: errBody} + } + + return responseBody, nil +} diff --git a/pkg/integrations/newrelic/example.go b/pkg/integrations/newrelic/example.go new file mode 100644 index 0000000000..d6c75e9fa4 --- /dev/null +++ b/pkg/integrations/newrelic/example.go @@ -0,0 +1,38 @@ +package newrelic + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_report_metric.json +var exampleOutputReportMetricBytes []byte + +//go:embed example_output_run_nrql_query.json +var exampleOutputRunNRQLQueryBytes []byte + +//go:embed example_data_on_issue.json +var exampleDataOnIssueBytes []byte + +var exampleOutputReportMetricOnce sync.Once +var exampleOutputReportMetric map[string]any + +var exampleOutputRunNRQLQueryOnce sync.Once +var exampleOutputRunNRQLQuery map[string]any + +var exampleDataOnIssueOnce sync.Once +var exampleDataOnIssue map[string]any + +func (c *ReportMetric) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputReportMetricOnce, exampleOutputReportMetricBytes, &exampleOutputReportMetric) +} + +func (c *RunNRQLQuery) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputRunNRQLQueryOnce, exampleOutputRunNRQLQueryBytes, &exampleOutputRunNRQLQuery) +} + +func (t *OnIssue) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue) +} diff --git a/pkg/integrations/newrelic/example_data_on_issue.json b/pkg/integrations/newrelic/example_data_on_issue.json new file mode 100644 index 0000000000..8f71bd2d5f --- /dev/null +++ b/pkg/integrations/newrelic/example_data_on_issue.json @@ -0,0 +1,17 @@ +{ + "data": { + "issueId": "MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "issueUrl": "https://one.newrelic.com/alerts-ai/issues/MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "title": "High CPU usage on production server", + "priority": "CRITICAL", + "state": "ACTIVATED", + "policyName": "Production Infrastructure", + "conditionName": "CPU usage > 90%", + "accountId": 1234567, + "createdAt": 1704067200000, + "updatedAt": 1704067260000, + "sources": ["newrelic"] + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "newrelic.issue" +} diff --git a/pkg/integrations/newrelic/example_output_report_metric.json b/pkg/integrations/newrelic/example_output_report_metric.json new file mode 100644 index 0000000000..eec9356e3b --- /dev/null +++ b/pkg/integrations/newrelic/example_output_report_metric.json @@ -0,0 +1,10 @@ +{ + "type": "newrelic.metric", + "data": { + "metricName": "custom.deployment.count", + "metricType": "count", + "value": 1, + "timestamp": 1704067200000 + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/integrations/newrelic/example_output_run_nrql_query.json b/pkg/integrations/newrelic/example_output_run_nrql_query.json new file mode 100644 index 0000000000..510579e16f --- /dev/null +++ b/pkg/integrations/newrelic/example_output_run_nrql_query.json @@ -0,0 +1,12 @@ +{ + "type": "newrelic.nrqlResult", + "data": { + "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago", + "results": [ + { + "count": 42567 + } + ] + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/integrations/newrelic/newrelic.go b/pkg/integrations/newrelic/newrelic.go new file mode 100644 index 0000000000..b7e5f78b26 --- /dev/null +++ b/pkg/integrations/newrelic/newrelic.go @@ -0,0 +1,187 @@ +package newrelic + +import ( + "context" + "fmt" + "regexp" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +var accountIDRegexp = regexp.MustCompile(`^\d+$`) + +const ( + NewRelicIssuePayloadType = "newrelic.issue" +) + +const installationInstructions = `### Getting your credentials + +1. **Account ID**: Click your name in the bottom-left corner of New Relic. Your Account ID is displayed under the account name. + +2. **User API Key**: Go to the **API Keys** page. Click **Create a key**. Select key type **User**. Give it a name (e.g. "SuperPlane") and click **Create a key**. This key is used for NerdGraph/NRQL queries — no additional permissions are needed. + +3. **License Key**: On the same **API Keys** page, find the key with type **Ingest - License** and copy it. This key is used for sending metrics. If no license key exists, click **Create a key** and select **Ingest - License**. + +4. **Region**: Choose **US** if your New Relic URL is ` + "`one.newrelic.com`" + `, or **EU** if it is ` + "`one.eu.newrelic.com`" + `. + +### Webhook Setup + +SuperPlane automatically creates a Webhook Notification Channel in your New Relic account when you add the **On Issue** trigger to your canvas. Just attach it to your alert workflow in New Relic to start receiving alerts. +` + +func init() { + registry.RegisterIntegrationWithWebhookHandler("newrelic", &NewRelic{}, &NewRelicWebhookHandler{}) +} + +type NewRelic struct{} + +type Configuration struct { + AccountID string `json:"accountId" mapstructure:"accountId"` + Region string `json:"region" mapstructure:"region"` + UserAPIKey string `json:"userApiKey" mapstructure:"userApiKey"` + LicenseKey string `json:"licenseKey" mapstructure:"licenseKey"` +} + +func (n *NewRelic) Name() string { + return "newrelic" +} + +func (n *NewRelic) Label() string { + return "New Relic" +} + +func (n *NewRelic) Icon() string { + return "chart-bar" +} + +func (n *NewRelic) Description() string { + return "React to alerts and query telemetry data from New Relic" +} + +func (n *NewRelic) Instructions() string { + return installationInstructions +} + +func (n *NewRelic) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "accountId", + Label: "Account ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "Your New Relic Account ID", + }, + { + Name: "region", + Label: "Region", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "US", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "US", Value: "US"}, + {Label: "EU", Value: "EU"}, + }, + }, + }, + Description: "New Relic data center region", + }, + { + Name: "userApiKey", + Label: "User API Key", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "New Relic User API Key for NerdGraph and NRQL queries", + }, + { + Name: "licenseKey", + Label: "License Key", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "New Relic License Key for metric ingestion", + }, + } +} + +func (n *NewRelic) Components() []core.Component { + return []core.Component{ + &ReportMetric{}, + &RunNRQLQuery{}, + } +} + +func (n *NewRelic) Triggers() []core.Trigger { + return []core.Trigger{ + &OnIssue{}, + } +} + +func (n *NewRelic) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (n *NewRelic) Sync(ctx core.SyncContext) error { + config := Configuration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode config: %v", err) + } + + if config.AccountID == "" { + return fmt.Errorf("accountId is required") + } + + if !accountIDRegexp.MatchString(config.AccountID) { + return fmt.Errorf("accountId must be numeric") + } + + if config.Region == "" { + return fmt.Errorf("region is required") + } + + if config.Region != "US" && config.Region != "EU" { + return fmt.Errorf("region must be US or EU, got %q", config.Region) + } + + if config.UserAPIKey == "" { + return fmt.Errorf("userApiKey is required") + } + + if config.LicenseKey == "" { + return fmt.Errorf("licenseKey is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + err = client.ValidateCredentials(context.Background()) + if err != nil { + return fmt.Errorf("invalid credentials: %v", err) + } + + ctx.Integration.Ready() + return nil +} + +func (n *NewRelic) HandleRequest(ctx core.HTTPRequestContext) { +} + +func (n *NewRelic) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + return []core.IntegrationResource{}, nil +} + +func (n *NewRelic) Actions() []core.Action { + return []core.Action{} +} + +func (n *NewRelic) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/newrelic/newrelic_test.go b/pkg/integrations/newrelic/newrelic_test.go new file mode 100644 index 0000000000..604eb8742e --- /dev/null +++ b/pkg/integrations/newrelic/newrelic_test.go @@ -0,0 +1,259 @@ +package newrelic + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__NewRelic__Sync(t *testing.T) { + n := &NewRelic{} + + t.Run("no accountId -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "accountId is required") + }) + + t.Run("non-numeric accountId -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "abc123", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "accountId must be numeric") + }) + + t.Run("invalid region -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "APAC", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "region must be US or EU") + }) + + t.Run("no region -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "region is required") + }) + + t.Run("no userApiKey -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "userApiKey is required") + }) + + t.Run("no licenseKey -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "licenseKey is required") + }) + + t.Run("successful validation -> ready", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data": {"actor": {"user": {"name": "Test User"}}}}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + HTTP: httpContext, + Integration: appCtx, + }) + + require.NoError(t, err) + assert.Equal(t, "ready", appCtx.State) + require.Len(t, httpContext.Requests, 1) + assert.Contains(t, httpContext.Requests[0].URL.String(), "api.newrelic.com/graphql") + assert.Equal(t, "test-user-api-key", httpContext.Requests[0].Header.Get("Api-Key")) + }) + + t.Run("EU region -> uses correct URL", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data": {"actor": {"user": {"name": "Test User"}}}}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "EU", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + HTTP: httpContext, + Integration: appCtx, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + assert.Contains(t, httpContext.Requests[0].URL.String(), "api.eu.newrelic.com/graphql") + }) + + t.Run("invalid credentials -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "invalid-key", + "licenseKey": "test-license-key", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + HTTP: httpContext, + Integration: appCtx, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid credentials") + assert.NotEqual(t, "ready", appCtx.State) + }) +} + +func Test__NewRelic__Components(t *testing.T) { + n := &NewRelic{} + components := n.Components() + + require.Len(t, components, 2) + assert.Equal(t, "newrelic.reportMetric", components[0].Name()) + assert.Equal(t, "newrelic.runNRQLQuery", components[1].Name()) +} + +func Test__NewRelic__Triggers(t *testing.T) { + n := &NewRelic{} + triggers := n.Triggers() + + require.Len(t, triggers, 1) + assert.Equal(t, "newrelic.onIssue", triggers[0].Name()) +} + +func Test__NewRelic__Configuration(t *testing.T) { + n := &NewRelic{} + config := n.Configuration() + + require.Len(t, config, 4) + + accountIdField := config[0] + assert.Equal(t, "accountId", accountIdField.Name) + assert.True(t, accountIdField.Required) + + regionField := config[1] + assert.Equal(t, "region", regionField.Name) + assert.True(t, regionField.Required) + + userAPIKeyField := config[2] + assert.Equal(t, "userApiKey", userAPIKeyField.Name) + assert.True(t, userAPIKeyField.Required) + assert.True(t, userAPIKeyField.Sensitive) + + licenseKeyField := config[3] + assert.Equal(t, "licenseKey", licenseKeyField.Name) + assert.True(t, licenseKeyField.Required) + assert.True(t, licenseKeyField.Sensitive) +} diff --git a/pkg/integrations/newrelic/on_issue.go b/pkg/integrations/newrelic/on_issue.go new file mode 100644 index 0000000000..141415d49b --- /dev/null +++ b/pkg/integrations/newrelic/on_issue.go @@ -0,0 +1,280 @@ +package newrelic + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +var validStatuses = []string{"CREATED", "ACTIVATED", "ACKNOWLEDGED", "CLOSED"} +var validPriorities = []string{"CRITICAL", "HIGH", "MEDIUM", "LOW"} + +type OnIssue struct{} + +type OnIssueConfiguration struct { + Statuses []string `json:"statuses" mapstructure:"statuses"` + Priorities []string `json:"priorities" mapstructure:"priorities"` +} + +type NewRelicIssuePayload struct { + IssueID string `json:"issueId"` + IssueURL string `json:"issueUrl"` + Title string `json:"title"` + Priority string `json:"priority"` + State string `json:"state"` + PolicyName string `json:"policyName"` + ConditionName string `json:"conditionName"` + AccountID any `json:"accountId"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Sources []string `json:"sources"` +} + +func (t *OnIssue) Name() string { + return "newrelic.onIssue" +} + +func (t *OnIssue) Label() string { + return "On Issue" +} + +func (t *OnIssue) Description() string { + return "Trigger when a New Relic alert issue occurs" +} + +func (t *OnIssue) Documentation() string { + return `The On Issue trigger starts a workflow execution when a New Relic alert issue is received via webhook. + +## What this trigger does + +- Receives New Relic webhook payloads for alert issues +- Filters by issue state (CREATED, ACTIVATED, ACKNOWLEDGED, CLOSED) +- Optionally filters by priority (CRITICAL, HIGH, MEDIUM, LOW) +- Emits matching issues as ` + "`newrelic.issue`" + ` events + +## Configuration + +- **Statuses**: Required list of issue states to listen for +- **Priorities**: Optional priority filter + +## Webhook Setup + +SuperPlane automatically creates a Webhook Notification Channel in your New Relic account. Just attach it to your alert workflow to start receiving alerts. +` +} + +func (t *OnIssue) Icon() string { + return "chart-bar" +} + +func (t *OnIssue) Color() string { + return "gray" +} + +func (t *OnIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "statuses", + Label: "Statuses", + Type: configuration.FieldTypeMultiSelect, + Required: true, + Default: []string{"CREATED", "ACTIVATED"}, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Created", Value: "CREATED"}, + {Label: "Activated", Value: "ACTIVATED"}, + {Label: "Acknowledged", Value: "ACKNOWLEDGED"}, + {Label: "Closed", Value: "CLOSED"}, + }, + }, + }, + Description: "Only emit issues with these states", + }, + { + Name: "priorities", + Label: "Priorities", + Type: configuration.FieldTypeMultiSelect, + Required: false, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Critical", Value: "CRITICAL"}, + {Label: "High", Value: "HIGH"}, + {Label: "Medium", Value: "MEDIUM"}, + {Label: "Low", Value: "LOW"}, + }, + }, + }, + Description: "Optional priority filter", + }, + } +} + +func (t *OnIssue) Setup(ctx core.TriggerContext) error { + if _, err := parseAndValidateOnIssueConfiguration(ctx.Configuration); err != nil { + return err + } + + return ctx.Integration.RequestWebhook(struct{}{}) +} + +func (t *OnIssue) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnIssue) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + if statusCode, err := validateWebhookAuth(ctx); err != nil { + return statusCode, err + } + + config, err := parseAndValidateOnIssueConfiguration(ctx.Configuration) + if err != nil { + return http.StatusInternalServerError, err + } + + var payload NewRelicIssuePayload + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("failed to parse request body: %w", err) + } + + filteredStatuses := filterEmptyStrings(config.Statuses) + if !containsIgnoreCase(filteredStatuses, payload.State) { + return http.StatusOK, nil + } + + filteredPriorities := filterEmptyStrings(config.Priorities) + if len(filteredPriorities) > 0 && !containsIgnoreCase(filteredPriorities, payload.Priority) { + return http.StatusOK, nil + } + + if err := ctx.Events.Emit(NewRelicIssuePayloadType, issueToMap(payload)); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to emit issue event: %w", err) + } + + return http.StatusOK, nil +} + +func (t *OnIssue) Cleanup(ctx core.TriggerContext) error { + return nil +} + +func parseAndValidateOnIssueConfiguration(configuration any) (OnIssueConfiguration, error) { + config := OnIssueConfiguration{} + if err := mapstructure.Decode(configuration, &config); err != nil { + return OnIssueConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + config = sanitizeOnIssueConfiguration(config) + if err := validateOnIssueConfiguration(config); err != nil { + return OnIssueConfiguration{}, err + } + + return config, nil +} + +func sanitizeOnIssueConfiguration(config OnIssueConfiguration) OnIssueConfiguration { + for i := range config.Statuses { + config.Statuses[i] = strings.ToUpper(strings.TrimSpace(config.Statuses[i])) + } + + for i := range config.Priorities { + config.Priorities[i] = strings.ToUpper(strings.TrimSpace(config.Priorities[i])) + } + + return config +} + +func validateOnIssueConfiguration(config OnIssueConfiguration) error { + statuses := filterEmptyStrings(config.Statuses) + if len(statuses) == 0 { + return fmt.Errorf("at least one status must be selected") + } + + for _, status := range statuses { + if !slices.Contains(validStatuses, status) { + return fmt.Errorf("invalid status %q", status) + } + } + + for _, priority := range filterEmptyStrings(config.Priorities) { + if !slices.Contains(validPriorities, priority) { + return fmt.Errorf("invalid priority %q", priority) + } + } + + return nil +} + +func filterEmptyStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != "" { + result = append(result, value) + } + } + return result +} + +func containsIgnoreCase(allowed []string, value string) bool { + for _, v := range allowed { + if strings.EqualFold(v, value) { + return true + } + } + return false +} + +func validateWebhookAuth(ctx core.WebhookRequestContext) (int, error) { + if ctx.Webhook == nil { + return http.StatusOK, nil + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to read webhook secret: %v", err) + } + + if len(secret) == 0 { + return http.StatusOK, nil + } + + authorization := ctx.Headers.Get("Authorization") + if !strings.HasPrefix(authorization, "Bearer ") { + return http.StatusForbidden, fmt.Errorf("missing bearer authorization") + } + + token := authorization[len("Bearer "):] + if subtle.ConstantTimeCompare([]byte(token), secret) != 1 { + return http.StatusForbidden, fmt.Errorf("invalid bearer token") + } + + return http.StatusOK, nil +} + +func issueToMap(payload NewRelicIssuePayload) map[string]any { + return map[string]any{ + "issueId": payload.IssueID, + "issueUrl": payload.IssueURL, + "title": payload.Title, + "priority": payload.Priority, + "state": payload.State, + "policyName": payload.PolicyName, + "conditionName": payload.ConditionName, + "accountId": payload.AccountID, + "createdAt": payload.CreatedAt, + "updatedAt": payload.UpdatedAt, + "sources": payload.Sources, + } +} diff --git a/pkg/integrations/newrelic/on_issue_test.go b/pkg/integrations/newrelic/on_issue_test.go new file mode 100644 index 0000000000..be0d85179f --- /dev/null +++ b/pkg/integrations/newrelic/on_issue_test.go @@ -0,0 +1,232 @@ +package newrelic + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnIssue__Setup(t *testing.T) { + trigger := &OnIssue{} + + t.Run("no statuses -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"statuses": []string{}}, + Integration: &contexts.IntegrationContext{}, + Webhook: &contexts.NodeWebhookContext{}, + }) + + require.ErrorContains(t, err, "at least one status must be selected") + }) + + t.Run("valid setup requests shared webhook", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{}, + } + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Integration: integrationCtx, + }) + + require.NoError(t, err) + require.Len(t, integrationCtx.WebhookRequests, 1) + }) +} + +func Test__OnIssue__HandleWebhook(t *testing.T) { + trigger := &OnIssue{} + payload := []byte(`{ + "issueId": "MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "issueUrl": "https://one.newrelic.com/alerts-ai/issues/MXxBSXxJU1NVRXwxMjM0NTY3ODk", + "title": "High CPU usage on production server", + "priority": "CRITICAL", + "state": "ACTIVATED", + "policyName": "Production Infrastructure", + "conditionName": "CPU usage > 90%", + "accountId": 1234567, + "createdAt": 1704067200000, + "updatedAt": 1704067260000, + "sources": ["newrelic"] + }`) + + t.Run("valid payload -> emits event", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Len(t, eventsCtx.Payloads, 1) + assert.Equal(t, NewRelicIssuePayloadType, eventsCtx.Payloads[0].Type) + data := eventsCtx.Payloads[0].Data.(map[string]any) + assert.Equal(t, "MXxBSXxJU1NVRXwxMjM0NTY3ODk", data["issueId"]) + assert.Equal(t, "ACTIVATED", data["state"]) + assert.Equal(t, "CRITICAL", data["priority"]) + }) + + t.Run("filtered by status -> skipped", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"CLOSED"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Len(t, eventsCtx.Payloads, 0) + }) + + t.Run("filtered by priority -> skipped", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}, "priorities": []string{"LOW"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Len(t, eventsCtx.Payloads, 0) + }) + + t.Run("malformed JSON -> 400 error", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte("not-json"), + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusBadRequest, code) + require.ErrorContains(t, err, "failed to parse request body") + assert.Len(t, eventsCtx.Payloads, 0) + }) + + t.Run("matching priority -> emits event", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}, "priorities": []string{"CRITICAL", "HIGH"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Len(t, eventsCtx.Payloads, 1) + }) + + t.Run("missing bearer token when secret configured -> 403", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{Secret: "my-secret"}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusForbidden, code) + require.ErrorContains(t, err, "missing bearer authorization") + assert.Len(t, eventsCtx.Payloads, 0) + }) + + t.Run("invalid bearer token -> 403", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + headers := http.Header{} + headers.Set("Authorization", "Bearer wrong-token") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: headers, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{Secret: "my-secret"}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusForbidden, code) + require.ErrorContains(t, err, "invalid bearer token") + assert.Len(t, eventsCtx.Payloads, 0) + }) + + t.Run("empty secret -> no auth required", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Len(t, eventsCtx.Payloads, 1) + }) + + t.Run("valid bearer token -> emits event", func(t *testing.T) { + eventsCtx := &contexts.EventContext{} + headers := http.Header{} + headers.Set("Authorization", "Bearer my-secret") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: headers, + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}}, + Webhook: &contexts.NodeWebhookContext{Secret: "my-secret"}, + Integration: &contexts.IntegrationContext{}, + Events: eventsCtx, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Len(t, eventsCtx.Payloads, 1) + }) +} + +func Test__OnIssue__Validation(t *testing.T) { + trigger := &OnIssue{} + + t.Run("invalid priority -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"statuses": []string{"ACTIVATED"}, "priorities": []string{"BANANA"}}, + Integration: &contexts.IntegrationContext{}, + Webhook: &contexts.NodeWebhookContext{}, + }) + + require.ErrorContains(t, err, "invalid priority") + }) +} diff --git a/pkg/integrations/newrelic/report_metric.go b/pkg/integrations/newrelic/report_metric.go new file mode 100644 index 0000000000..f825e32955 --- /dev/null +++ b/pkg/integrations/newrelic/report_metric.go @@ -0,0 +1,330 @@ +package newrelic + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type ReportMetric struct{} + +type ReportMetricSpec struct { + MetricName string `json:"metricName" mapstructure:"metricName"` + MetricType string `json:"metricType" mapstructure:"metricType"` + Value any `json:"value" mapstructure:"value"` + Attributes map[string]any `json:"attributes" mapstructure:"attributes"` + Timestamp *int64 `json:"timestamp" mapstructure:"timestamp"` + Interval *int64 `json:"interval" mapstructure:"interval"` +} + +const defaultIntervalMs = 60000 + +func (c *ReportMetric) Name() string { + return "newrelic.reportMetric" +} + +func (c *ReportMetric) Label() string { + return "Report Metric" +} + +func (c *ReportMetric) Description() string { + return "Send custom metric data to New Relic" +} + +func (c *ReportMetric) Icon() string { + return "chart-bar" +} + +func (c *ReportMetric) Color() string { + return "gray" +} + +func (c *ReportMetric) Documentation() string { + return `The Report Metric component sends custom metric data to New Relic's Metric API. + +## Use Cases + +- **Deployment metrics**: Track deployment frequency and duration +- **Business metrics**: Report custom KPIs like revenue, signups, or conversion rates +- **Pipeline metrics**: Measure workflow execution times and success rates + +## Configuration + +- ` + "`metricName`" + `: The name of the metric (e.g., custom.deployment.count) +- ` + "`metricType`" + `: The type of metric (gauge, count, or summary) +- ` + "`value`" + `: The numeric value for the metric +- ` + "`attributes`" + `: Optional key-value labels for the metric +- ` + "`timestamp`" + `: Optional Unix epoch milliseconds (defaults to now) + +## Outputs + +The component emits a metric confirmation containing: +- ` + "`metricName`" + `: The name of the reported metric +- ` + "`metricType`" + `: The type of the metric +- ` + "`value`" + `: The reported value +- ` + "`timestamp`" + `: The timestamp used +` +} + +func (c *ReportMetric) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *ReportMetric) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "metricName", + Label: "Metric Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "The name of the metric (e.g., custom.deployment.count)", + Placeholder: "custom.deployment.count", + }, + { + Name: "metricType", + Label: "Metric Type", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "gauge", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Gauge", Value: "gauge"}, + {Label: "Count", Value: "count"}, + {Label: "Summary", Value: "summary"}, + }, + }, + }, + Description: "The type of metric to report", + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + Description: "The numeric value for the metric", + }, + { + Name: "attributes", + Label: "Attributes", + Type: configuration.FieldTypeObject, + Required: false, + Description: "Optional key-value attributes for the metric", + }, + { + Name: "timestamp", + Label: "Timestamp", + Type: configuration.FieldTypeNumber, + Required: false, + Description: "Optional Unix epoch milliseconds (defaults to now)", + }, + { + Name: "interval", + Label: "Interval (ms)", + Type: configuration.FieldTypeNumber, + Required: false, + Default: defaultIntervalMs, + Description: "Interval in milliseconds for count and summary metrics", + }, + } +} + +func (c *ReportMetric) Setup(ctx core.SetupContext) error { + spec := ReportMetricSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.MetricName == "" { + return errors.New("metricName is required") + } + + if spec.MetricType == "" { + return errors.New("metricType is required") + } + + validMetricTypes := []string{"gauge", "count", "summary"} + if !slices.Contains(validMetricTypes, spec.MetricType) { + return fmt.Errorf("invalid metricType %q, must be one of: gauge, count, summary", spec.MetricType) + } + + if spec.Value == nil { + return errors.New("value is required") + } + + if spec.MetricType == "summary" { + valueMap, ok := spec.Value.(map[string]any) + if !ok { + return errors.New("summary metric value must be an object with keys: count, sum, min, max") + } + + requiredKeys := []string{"count", "sum", "min", "max"} + for _, key := range requiredKeys { + if _, exists := valueMap[key]; !exists { + return fmt.Errorf("summary metric value missing required key %q", key) + } + } + } + + return nil +} + +func (c *ReportMetric) Execute(ctx core.ExecutionContext) error { + spec := ReportMetricSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + timestamp := time.Now().UnixMilli() + if spec.Timestamp != nil && *spec.Timestamp > 0 { + timestamp = *spec.Timestamp + } + + numericValue, err := toNumericValue(spec.MetricType, spec.Value) + if err != nil { + return fmt.Errorf("invalid metric value: %v", err) + } + + metric := map[string]any{ + "name": spec.MetricName, + "type": spec.MetricType, + "value": numericValue, + "timestamp": timestamp, + } + + if spec.MetricType == "count" || spec.MetricType == "summary" { + intervalMs := int64(defaultIntervalMs) + if spec.Interval != nil { + intervalMs = *spec.Interval + } + metric["interval.ms"] = intervalMs + } + + if spec.Attributes != nil && len(spec.Attributes) > 0 { + metric["attributes"] = spec.Attributes + } + + payload := []map[string]any{ + { + "metrics": []map[string]any{metric}, + }, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("error marshaling payload: %v", err) + } + + _, err = client.ReportMetric(context.Background(), payloadBytes) + if err != nil { + return fmt.Errorf("failed to report metric: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "newrelic.metric", + []any{map[string]any{ + "metricName": spec.MetricName, + "metricType": spec.MetricType, + "value": numericValue, + "timestamp": timestamp, + }}, + ) +} + +func (c *ReportMetric) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *ReportMetric) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *ReportMetric) Actions() []core.Action { + return []core.Action{} +} + +func (c *ReportMetric) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *ReportMetric) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *ReportMetric) Cleanup(ctx core.SetupContext) error { + return nil +} + +// toNumericValue coerces a metric value to the numeric type expected by the +// New Relic Metric API. For gauge and count metrics it returns a float64. +// For summary metrics it returns a map with float64 values. +func toNumericValue(metricType string, value any) (any, error) { + if metricType == "summary" { + return toSummaryValue(value) + } + + return toFloat64(value) +} + +func toFloat64(v any) (float64, error) { + switch n := v.(type) { + case float64: + return n, nil + case float32: + return float64(n), nil + case int: + return float64(n), nil + case int64: + return float64(n), nil + case string: + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return 0, fmt.Errorf("cannot convert %q to a number", n) + } + return f, nil + case json.Number: + return n.Float64() + default: + return 0, fmt.Errorf("unsupported value type %T", v) + } +} + +func toSummaryValue(value any) (map[string]any, error) { + m, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("summary value must be an object") + } + + result := make(map[string]any, len(m)) + for _, key := range []string{"count", "sum", "min", "max"} { + v, exists := m[key] + if !exists { + return nil, fmt.Errorf("summary value missing required key %q", key) + } + f, err := toFloat64(v) + if err != nil { + return nil, fmt.Errorf("summary key %q: %w", key, err) + } + result[key] = f + } + + return result, nil +} diff --git a/pkg/integrations/newrelic/report_metric_test.go b/pkg/integrations/newrelic/report_metric_test.go new file mode 100644 index 0000000000..4c6dda457c --- /dev/null +++ b/pkg/integrations/newrelic/report_metric_test.go @@ -0,0 +1,172 @@ +package newrelic + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__ReportMetric__Setup(t *testing.T) { + component := &ReportMetric{} + + t.Run("missing metricName -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "metricName": "", + "metricType": "gauge", + "value": 1, + }, + }) + + require.ErrorContains(t, err, "metricName is required") + }) + + t.Run("missing metricType -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "metricName": "custom.metric", + "metricType": "", + "value": 1, + }, + }) + + require.ErrorContains(t, err, "metricType is required") + }) + + t.Run("invalid metricType -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "metricName": "custom.metric", + "metricType": "invalid", + "value": 1, + }, + }) + + require.ErrorContains(t, err, "invalid metricType") + }) + + t.Run("missing value -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "metricName": "custom.metric", + "metricType": "gauge", + }, + }) + + require.ErrorContains(t, err, "value is required") + }) + + t.Run("valid configuration -> success", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "metricName": "custom.deployment.count", + "metricType": "count", + "value": 1, + }, + }) + + require.NoError(t, err) + }) +} + +func Test__ReportMetric__Execute(t *testing.T) { + component := &ReportMetric{} + + t.Run("successful metric report", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusAccepted, + Body: io.NopCloser(strings.NewReader(`{"requestId": "abc123"}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "metricName": "custom.deployment.count", + "metricType": "count", + "value": 1, + }, + HTTP: httpContext, + Integration: appCtx, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "newrelic.metric", executionState.Type) + + require.Len(t, httpContext.Requests, 1) + req := httpContext.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Contains(t, req.URL.String(), "metric-api.newrelic.com/metric/v1") + assert.Equal(t, "test-license-key", req.Header.Get("Api-Key")) + }) + + t.Run("API error -> returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error": "Invalid payload"}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "metricName": "custom.metric", + "metricType": "gauge", + "value": 1, + }, + HTTP: httpContext, + Integration: appCtx, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to report metric") + }) +} + +func Test__ReportMetric__OutputChannels(t *testing.T) { + component := &ReportMetric{} + channels := component.OutputChannels(nil) + + require.Len(t, channels, 1) + assert.Equal(t, "default", channels[0].Name) +} diff --git a/pkg/integrations/newrelic/run_nrql_query.go b/pkg/integrations/newrelic/run_nrql_query.go new file mode 100644 index 0000000000..3acbfaf915 --- /dev/null +++ b/pkg/integrations/newrelic/run_nrql_query.go @@ -0,0 +1,173 @@ +package newrelic + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const maxNRQLTimeout = 120 // seconds + +type RunNRQLQuery struct{} + +type RunNRQLQuerySpec struct { + Query string `json:"query" mapstructure:"query"` + Timeout int `json:"timeout" mapstructure:"timeout"` +} + +func (c *RunNRQLQuery) Name() string { + return "newrelic.runNRQLQuery" +} + +func (c *RunNRQLQuery) Label() string { + return "Run NRQL Query" +} + +func (c *RunNRQLQuery) Description() string { + return "Run a NRQL query against New Relic data via NerdGraph" +} + +func (c *RunNRQLQuery) Icon() string { + return "chart-bar" +} + +func (c *RunNRQLQuery) Color() string { + return "gray" +} + +func (c *RunNRQLQuery) Documentation() string { + return `The Run NRQL Query component executes a NRQL query against New Relic data via the NerdGraph API. + +## Use Cases + +- **Health checks**: Query application error rates or response times before deployments +- **Capacity planning**: Check resource utilization metrics +- **Incident investigation**: Query telemetry data during incident workflows + +## Configuration + +- ` + "`query`" + `: The NRQL query string to execute +- ` + "`timeout`" + `: Optional query timeout in seconds (default: 30) + +## Outputs + +The component emits query results containing: +- ` + "`query`" + `: The executed NRQL query +- ` + "`results`" + `: Array of result rows returned by the query +` +} + +func (c *RunNRQLQuery) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *RunNRQLQuery) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "query", + Label: "NRQL Query", + Type: configuration.FieldTypeText, + Required: true, + Description: "The NRQL query to execute", + Placeholder: "SELECT count(*) FROM Transaction SINCE 1 hour ago", + }, + { + Name: "timeout", + Label: "Timeout (seconds)", + Type: configuration.FieldTypeNumber, + Required: false, + Default: 30, + TypeOptions: &configuration.TypeOptions{ + Number: &configuration.NumberTypeOptions{ + Min: func() *int { min := 0; return &min }(), + Max: func() *int { max := maxNRQLTimeout; return &max }(), + }, + }, + Description: "Query timeout in seconds", + }, + } +} + +func (c *RunNRQLQuery) Setup(ctx core.SetupContext) error { + spec := RunNRQLQuerySpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.Query == "" { + return errors.New("query is required") + } + + if spec.Timeout > maxNRQLTimeout { + return fmt.Errorf("timeout cannot exceed %d seconds", maxNRQLTimeout) + } + + return nil +} + +func (c *RunNRQLQuery) Execute(ctx core.ExecutionContext) error { + spec := RunNRQLQuerySpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + timeout := 30 + if spec.Timeout > 0 { + timeout = spec.Timeout + } + + if timeout > maxNRQLTimeout { + timeout = maxNRQLTimeout + } + + results, err := client.RunNRQLQuery(context.Background(), spec.Query, timeout) + if err != nil { + return fmt.Errorf("failed to execute NRQL query: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "newrelic.nrqlResult", + []any{map[string]any{ + "query": spec.Query, + "results": results, + }}, + ) +} + +func (c *RunNRQLQuery) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *RunNRQLQuery) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *RunNRQLQuery) Actions() []core.Action { + return []core.Action{} +} + +func (c *RunNRQLQuery) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *RunNRQLQuery) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *RunNRQLQuery) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/newrelic/run_nrql_query_test.go b/pkg/integrations/newrelic/run_nrql_query_test.go new file mode 100644 index 0000000000..e0973fa7e4 --- /dev/null +++ b/pkg/integrations/newrelic/run_nrql_query_test.go @@ -0,0 +1,199 @@ +package newrelic + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__RunNRQLQuery__Setup(t *testing.T) { + component := &RunNRQLQuery{} + + t.Run("missing query -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "", + }, + }) + + require.ErrorContains(t, err, "query is required") + }) + + t.Run("timeout exceeds max -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago", + "timeout": 999, + }, + }) + + require.ErrorContains(t, err, "timeout cannot exceed 120 seconds") + }) + + t.Run("valid configuration -> success", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago", + }, + }) + + require.NoError(t, err) + }) +} + +func Test__RunNRQLQuery__Execute(t *testing.T) { + component := &RunNRQLQuery{} + + t.Run("successful query -> emits results", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": { + "actor": { + "account": { + "nrql": { + "results": [{"count": 42567}] + } + } + } + } + }`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago", + }, + HTTP: httpContext, + Integration: appCtx, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "default", executionState.Channel) + assert.Equal(t, "newrelic.nrqlResult", executionState.Type) + + require.Len(t, httpContext.Requests, 1) + req := httpContext.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Contains(t, req.URL.String(), "api.newrelic.com/graphql") + assert.Equal(t, "test-user-api-key", req.Header.Get("Api-Key")) + }) + + t.Run("GraphQL error -> returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": null, + "errors": [{"message": "Invalid NRQL query"}] + }`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "INVALID QUERY", + }, + HTTP: httpContext, + Integration: appCtx, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "GraphQL error") + }) + + t.Run("empty results -> emits empty array", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "data": { + "actor": { + "account": { + "nrql": { + "results": [] + } + } + } + } + }`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "accountId": "12345", + "region": "US", + "userApiKey": "test-user-api-key", + "licenseKey": "test-license-key", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: make(map[string]string), + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "SELECT count(*) FROM Transaction WHERE error IS TRUE SINCE 1 hour ago", + }, + HTTP: httpContext, + Integration: appCtx, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + }) +} + +func Test__RunNRQLQuery__OutputChannels(t *testing.T) { + component := &RunNRQLQuery{} + channels := component.OutputChannels(nil) + + require.Len(t, channels, 1) + assert.Equal(t, "default", channels[0].Name) +} diff --git a/pkg/integrations/newrelic/webhook_handler.go b/pkg/integrations/newrelic/webhook_handler.go new file mode 100644 index 0000000000..bc4635e9b8 --- /dev/null +++ b/pkg/integrations/newrelic/webhook_handler.go @@ -0,0 +1,117 @@ +package newrelic + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +// WebhookProvisioningMetadata stores the IDs of the destination and channel +// created in New Relic so they can be cleaned up later. +type WebhookProvisioningMetadata struct { + DestinationID string `json:"destinationId" mapstructure:"destinationId"` + ChannelID string `json:"channelId" mapstructure:"channelId"` +} + +// defaultPayloadTemplate is a New Relic Handlebars template that maps +// issue fields to the NewRelicIssuePayload struct expected by HandleWebhook. +const defaultPayloadTemplate = `{ + "issueId": {{json issueId}}, + "issueUrl": {{json issueUrl}}, + "title": {{#if title.[0]}}{{json title.[0]}}{{else}}null{{/if}}, + "priority": {{json priority}}, + "state": {{json state}}, + "policyName": {{#if policyName.[0]}}{{json policyName.[0]}}{{else}}null{{/if}}, + "conditionName": {{#if conditionName.[0]}}{{json conditionName.[0]}}{{else}}null{{/if}}, + "accountId": {{#if accumulations.tag.account.[0]}}{{json accumulations.tag.account.[0]}}{{else}}null{{/if}}, + "createdAt": {{json createdAt}}, + "updatedAt": {{json updatedAt}}, + "sources": {{json sources}} +}` + +type NewRelicWebhookHandler struct{} + +func (h *NewRelicWebhookHandler) CompareConfig(a any, b any) (bool, error) { + return true, nil +} + +// Setup creates a webhook destination and notification channel in the user's +// New Relic account via NerdGraph, so alerts are forwarded automatically. +func (h *NewRelicWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create New Relic client: %w", err) + } + + secret := uuid.New().String() + if err := ctx.Webhook.SetSecret([]byte(secret)); err != nil { + return nil, fmt.Errorf("failed to persist webhook secret: %w", err) + } + + destinationID, err := client.CreateNotificationDestination(context.Background(), ctx.Webhook.GetURL(), secret) + if err != nil { + return nil, fmt.Errorf("failed to create webhook destination: %w", err) + } + + channelID, err := client.CreateNotificationChannel(context.Background(), destinationID, defaultPayloadTemplate) + if err != nil { + // Best-effort cleanup of the destination we just created. + _ = client.DeleteNotificationDestination(context.Background(), destinationID) + return nil, fmt.Errorf("failed to create notification channel: %w", err) + } + + return WebhookProvisioningMetadata{ + DestinationID: destinationID, + ChannelID: channelID, + }, nil +} + +// Cleanup deletes the notification channel and destination from New Relic. +func (h *NewRelicWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + metadata := WebhookProvisioningMetadata{} + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &metadata); err != nil { + return fmt.Errorf("failed to decode webhook metadata: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create New Relic client: %w", err) + } + + if metadata.ChannelID != "" { + if err := client.DeleteNotificationChannel(context.Background(), metadata.ChannelID); err != nil && !isNotFoundOrUnauthorized(err) { + return fmt.Errorf("failed to delete notification channel: %w", err) + } + } + + if metadata.DestinationID != "" { + if err := client.DeleteNotificationDestination(context.Background(), metadata.DestinationID); err != nil && !isNotFoundOrUnauthorized(err) { + return fmt.Errorf("failed to delete notification destination: %w", err) + } + } + + return nil +} + +// isNotFoundOrUnauthorized returns true if the error is an API error with +// status 401 or 404, meaning the resource is already gone or credentials +// are no longer valid. In both cases cleanup can be considered complete. +func isNotFoundOrUnauthorized(err error) bool { + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusNotFound + } + return false +} + +// Merge always keeps the current config because all New Relic triggers share +// a single integration-level webhook with no trigger-specific configuration. +// CompareConfig returns true so all triggers route to the same webhook. +func (h *NewRelicWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 7f962caaeb..60fef39520 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -61,6 +61,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/jfrog_artifactory" _ "github.com/superplanehq/superplane/pkg/integrations/jira" _ "github.com/superplanehq/superplane/pkg/integrations/launchdarkly" + _ "github.com/superplanehq/superplane/pkg/integrations/newrelic" _ "github.com/superplanehq/superplane/pkg/integrations/octopus" _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" diff --git a/web_src/src/assets/icons/integrations/newrelic.svg b/web_src/src/assets/icons/integrations/newrelic.svg new file mode 100644 index 0000000000..078e746165 --- /dev/null +++ b/web_src/src/assets/icons/integrations/newrelic.svg @@ -0,0 +1 @@ + diff --git a/web_src/src/pages/organization/settings/IntegrationDetails.tsx b/web_src/src/pages/organization/settings/IntegrationDetails.tsx index c5f9f198a1..b1c537c99c 100644 --- a/web_src/src/pages/organization/settings/IntegrationDetails.tsx +++ b/web_src/src/pages/organization/settings/IntegrationDetails.tsx @@ -47,9 +47,11 @@ export function IntegrationDetails({ organizationId }: IntegrationDetailsProps) const deleteMutation = useDeleteIntegration(organizationId, integrationId || ""); // Initialize config values when installation loads + const [configLoaded, setConfigLoaded] = useState(false); useEffect(() => { if (integration?.spec?.configuration) { setConfigValues(integration.spec.configuration); + setConfigLoaded(true); } }, [integration]); @@ -329,19 +331,20 @@ export function IntegrationDetails({ organizationId }: IntegrationDetailsProps)

A unique name for this integration

- {integrationDef.configuration.map((field: ConfigurationField) => ( - setConfigValues({ ...configValues, [field.name!]: value })} - allValues={configValues} - domainId={organizationId} - domainType="DOMAIN_TYPE_ORGANIZATION" - organizationId={organizationId} - appInstallationId={integration?.metadata?.id} - /> - ))} + {configLoaded && + integrationDef.configuration.map((field: ConfigurationField) => ( + setConfigValues({ ...configValues, [field.name!]: value })} + allValues={configValues} + domainId={organizationId} + domainType="DOMAIN_TYPE_ORGANIZATION" + organizationId={organizationId} + appInstallationId={integration?.metadata?.id} + /> + ))}