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}
+ />
+ ))}