diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index cc82b7e62e..4692ad3437 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -25,8 +25,8 @@ services:
# Ensure Go build cache initializes in a writable, persisted location
# This fixes: "failed to initialize build cache at /.cache/go-build: permission denied"
# and keeps the cache across container restarts (since /app is bind-mounted)
- GOMODCACHE: "/app/tmp/go/pkg/mod"
- GOCACHE: "/app/tmp/go-build"
+ GOMODCACHE: "/app/tmp/.go/pkg/mod"
+ GOCACHE: "/app/tmp/.go-build"
XDG_CACHE_HOME: "/app/tmp"
# Lock Playwright browsers location so install and runtime match
PLAYWRIGHT_BROWSERS_PATH: "/app/tmp/ms-playwright"
diff --git a/docs/components/New Relic.mdx b/docs/components/New Relic.mdx
new file mode 100644
index 0000000000..666f8aed13
--- /dev/null
+++ b/docs/components/New Relic.mdx
@@ -0,0 +1,181 @@
+---
+title: "New Relic"
+---
+
+Monitor and manage your New Relic resources
+
+## Triggers
+
+
+
+
+
+import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+## Actions
+
+
+
+
+
+
+## Instructions
+
+To set up New Relic integration:
+
+1. **Select Region**: Choose your New Relic region (US or EU)
+2. **Provide API Keys**: You can provide one or both keys depending on which components you need.
+
+## API Keys
+
+New Relic uses two different types of API keys for different purposes:
+
+- **User API Key** (starts with NRAK-): Required for **Run NRQL Query** and **On Issue** trigger. Get it from New Relic > Account Settings > API Keys > Create User Key.
+- **License Key** (Ingest - License): Required for **Report Metric** action. Get it from New Relic > Account Settings > API Keys > Create Ingest License Key.
+
+You may provide both keys to enable all components, or just the key(s) for the components you need.
+
+
+
+## On Issue
+
+The On Issue trigger starts a workflow execution when New Relic issues are created or updated.
+
+### Use Cases
+
+- **Incident Response**: automated remediation or notification when critical issues occur.
+- **Sync**: synchronize New Relic issues with Jira or other tracking systems.
+
+### Configuration
+
+- **Priorities**: Filter by priority (CRITICAL, HIGH, MEDIUM, LOW). Leave empty for all.
+- **States**: Filter by state (ACTIVATED, CLOSED, CREATED). Leave empty for all.
+
+### Webhook Setup
+
+This trigger generates a webhook URL. You must configure a **Workflow** in New Relic to send a webhook to this URL.
+
+**IMPORTANT**: You must use the following JSON payload template in your New Relic Webhook configuration:
+
+```json
+{
+ "issue_id": "{{issueId}}",
+ "title": "{{annotations.title.[0]}}",
+ "priority": "{{priority}}",
+ "issue_url": "{{issuePageUrl}}",
+ "state": "{{state}}",
+ "owner": "{{owner}}"
+}
+```
+
+### Example Data
+
+```json
+{
+ "issueId": "12345678-abcd-efgh-ijkl-1234567890ab",
+ "issueUrl": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=",
+ "owner": "Team SRE",
+ "priority": "CRITICAL",
+ "state": "ACTIVATED",
+ "title": "High CPU Usage"
+}
+```
+
+
+
+## Report Metric
+
+The Report Metric component allows you to send custom metrics (Gauge, Count, Summary) to New Relic.
+
+### Configuration
+
+- **Metric Name**: The name of the metric (e.g., "server.cpu.usage")
+- **Metric Type**: The type of metric (Gauge, Count, or Summary)
+- **Value**: The numeric value of the metric
+- **Timestamp**: Optional Unix timestamp (milliseconds). Defaults to now.
+- **Interval (ms)**: Required for Count and Summary metrics. The duration of the measurement window in milliseconds.
+- **Attributes**: Optional JSON object with additional attributes
+
+### Output
+
+Returns the sent metric payload.
+
+### Example Output
+
+```json
+{
+ "intervalMs": 0,
+ "name": "server.cpu.usage",
+ "status": "202 Accepted",
+ "timestamp": 1707552000000,
+ "type": "gauge",
+ "value": 75.5
+}
+```
+
+
+
+## Run NRQL Query
+
+The Run NRQL Query component allows you to execute NRQL queries via New Relic's NerdGraph API.
+
+### Use Cases
+
+- **Data retrieval**: Query telemetry data, metrics, events, and logs
+- **Custom analytics**: Build custom analytics and reporting workflows
+- **Monitoring**: Retrieve monitoring data for downstream processing
+- **Alerting**: Query data to make decisions in workflow logic
+
+### Configuration
+
+- **Account**: The New Relic account to query (select from dropdown)
+- **Query**: The NRQL query string to execute (required)
+- **Timeout**: Query timeout in seconds (optional, default: 10, max: 120)
+
+### Output
+
+Returns query results including:
+- **results**: Array of query result objects
+- **totalResult**: Aggregated result for queries with aggregation functions
+- **metadata**: Query metadata (event types, facets, messages, time window)
+- **query**: The original NRQL query executed
+- **accountId**: The account ID queried
+
+### Example Queries
+
+- Count transactions: `SELECT count(*) FROM Transaction SINCE 1 hour ago`
+- Average response time: `SELECT average(duration) FROM Transaction SINCE 1 day ago`
+- Faceted query: `SELECT count(*) FROM Transaction FACET appName SINCE 1 hour ago`
+
+### Notes
+
+- Requires a valid New Relic API key with query permissions
+- Queries are subject to New Relic's NRQL query limits
+- Invalid NRQL syntax will return an error from the API
+
+### Example Output
+
+```json
+{
+ "accountId": "1234567",
+ "metadata": {
+ "eventTypes": [
+ "Transaction"
+ ],
+ "facets": null,
+ "messages": [],
+ "timeWindow": {
+ "begin": 1707559740000,
+ "end": 1707563340000
+ }
+ },
+ "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago",
+ "results": [
+ {
+ "count": 1523
+ }
+ ],
+ "totalResult": null
+}
+```
+
diff --git a/pkg/integrations/newrelic/client.go b/pkg/integrations/newrelic/client.go
new file mode 100644
index 0000000000..998104effd
--- /dev/null
+++ b/pkg/integrations/newrelic/client.go
@@ -0,0 +1,326 @@
+package newrelic
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type Client struct {
+ UserAPIKey string
+ LicenseKey string
+ NerdGraphURL string
+ MetricBaseURL string
+ http core.HTTPContext
+}
+
+func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) {
+ userAPIKey := ""
+ if raw, err := ctx.GetConfig("userApiKey"); err == nil {
+ userAPIKey = strings.TrimSpace(string(raw))
+ }
+
+ licenseKey := ""
+ if raw, err := ctx.GetConfig("licenseKey"); err == nil {
+ licenseKey = strings.TrimSpace(string(raw))
+ }
+
+ if userAPIKey == "" && licenseKey == "" {
+ return nil, fmt.Errorf("at least one API key is required: provide a User API Key and/or a License Key")
+ }
+
+ site, err := ctx.GetConfig("site")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get site: %w", err)
+ }
+
+ var nerdGraphURL, metricBaseURL string
+ if string(site) == "EU" {
+ nerdGraphURL = nerdGraphAPIBaseEU
+ metricBaseURL = metricsAPIBaseEU
+ } else {
+ nerdGraphURL = nerdGraphAPIBaseUS
+ metricBaseURL = metricsAPIBaseUS
+ }
+
+ return &Client{
+ UserAPIKey: userAPIKey,
+ LicenseKey: licenseKey,
+ NerdGraphURL: nerdGraphURL,
+ MetricBaseURL: metricBaseURL,
+ http: httpCtx,
+ }, nil
+}
+
+type MetricType string
+
+const (
+ MetricTypeGauge MetricType = "gauge"
+ MetricTypeCount MetricType = "count"
+ MetricTypeSummary MetricType = "summary"
+)
+
+type Metric struct {
+ Name string `json:"name"`
+ Type MetricType `json:"type"`
+ Value any `json:"value"`
+ Timestamp int64 `json:"timestamp,omitempty"`
+ IntervalMs int64 `json:"interval.ms,omitempty"`
+ Attributes map[string]any `json:"attributes,omitempty"`
+}
+
+type MetricBatch struct {
+ Common *map[string]any `json:"common,omitempty"`
+ Metrics []Metric `json:"metrics"`
+}
+
+func (c *Client) ReportMetric(ctx context.Context, batch []MetricBatch) error {
+ url := c.MetricBaseURL
+
+ bodyBytes, err := json.Marshal(batch)
+ if err != nil {
+ return fmt.Errorf("failed to marshal metric batch: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create metric request: %w", err)
+ }
+
+ // Use License Key with X-License-Key header; fall back to User API Key
+ if c.LicenseKey != "" {
+ req.Header.Set("X-License-Key", c.LicenseKey)
+ } else {
+ req.Header.Set("Api-Key", c.UserAPIKey)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to report metrics: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return parseErrorResponse(url, body, resp.StatusCode)
+ }
+
+ return nil
+}
+
+// doNerdGraphRequest handles the shared boilerplate for all GraphQL calls to New Relic
+func (c *Client) doNerdGraphRequest(ctx context.Context, query string, variables map[string]any, outData any) error {
+ gqlRequest := GraphQLRequest{
+ Query: query,
+ Variables: variables,
+ }
+
+ bodyBytes, err := json.Marshal(gqlRequest)
+ if err != nil {
+ return fmt.Errorf("failed to marshal GraphQL request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.NerdGraphURL, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create NerdGraph request: %w", err)
+ }
+
+ req.Header.Set("Api-Key", c.UserAPIKey)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to execute NerdGraph request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return parseErrorResponse(c.NerdGraphURL, responseBody, resp.StatusCode)
+ }
+
+ var gqlResponse GraphQLResponse
+ if err := json.Unmarshal(responseBody, &gqlResponse); err != nil {
+ return fmt.Errorf("failed to decode GraphQL response: %w", err)
+ }
+
+ if len(gqlResponse.Errors) > 0 {
+ var errMessages []string
+ for _, gqlErr := range gqlResponse.Errors {
+ errMessages = append(errMessages, gqlErr.Message)
+ }
+ return fmt.Errorf("GraphQL errors: %s", strings.Join(errMessages, "; "))
+ }
+
+ // Marshal the map back to JSON and Unmarshal into the specific struct 'outData'
+ // This is the cleanest way to map a map[string]any to a specific struct
+ dataBytes, err := json.Marshal(gqlResponse.Data)
+ if err != nil {
+ return fmt.Errorf("failed to re-marshal data: %w", err)
+ }
+
+ return json.Unmarshal(dataBytes, outData)
+}
+
+func (c *Client) ValidateAPIKey(ctx context.Context) error {
+ query := `{ actor { user { name email } } }`
+ var out any // We don't actually need the data for validation, just the error check
+ return c.doNerdGraphRequest(ctx, query, nil, &out)
+}
+
+// ListAccounts fetches the list of accounts the API key has access to
+func (c *Client) ListAccounts(ctx context.Context) ([]Account, error) {
+ query := `{ actor { accounts { id name } } }`
+ var response struct {
+ Actor struct {
+ Accounts []Account `json:"accounts"`
+ } `json:"actor"`
+ }
+
+ if err := c.doNerdGraphRequest(ctx, query, nil, &response); err != nil {
+ return nil, err
+ }
+ return response.Actor.Accounts, nil
+}
+
+type GraphQLRequest struct {
+ Query string `json:"query"`
+ Variables map[string]interface{} `json:"variables,omitempty"`
+}
+
+type GraphQLResponse struct {
+ Data map[string]interface{} `json:"data"`
+ Errors []GraphQLError `json:"errors,omitempty"`
+}
+
+type GraphQLError struct {
+ Message string `json:"message"`
+ Path []interface{} `json:"path,omitempty"`
+}
+
+type NRQLQueryResponse struct {
+ Results []map[string]interface{} `json:"results"`
+ TotalResult map[string]interface{} `json:"totalResult,omitempty"`
+ Metadata *NRQLMetadata `json:"metadata,omitempty"`
+}
+
+type NRQLMetadata struct {
+ EventTypes []string `json:"eventTypes,omitempty"`
+ Facets []string `json:"facets,omitempty"`
+ Messages []string `json:"messages,omitempty"`
+ TimeWindow *TimeWindow `json:"timeWindow,omitempty"`
+}
+
+type TimeWindow struct {
+ Begin int64 `json:"begin"`
+ End int64 `json:"end"`
+}
+
+func (c *Client) RunNRQLQuery(ctx context.Context, accountID int64, query string, timeout int) (*NRQLQueryResponse, error) {
+ graphqlQuery := fmt.Sprintf(`{
+ actor {
+ account(id: %d) {
+ nrql(query: %s, timeout: %d) {
+ results
+ totalResult
+ metadata {
+ eventTypes
+ facets
+ messages
+ timeWindow {
+ begin
+ end
+ }
+ }
+ }
+ }
+ }
+ }`, accountID, strconv.Quote(query), timeout)
+
+ gqlRequest := GraphQLRequest{
+ Query: graphqlQuery,
+ }
+
+ bodyBytes, err := json.Marshal(gqlRequest)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.NerdGraphURL, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create NerdGraph request: %w", err)
+ }
+
+ req.Header.Set("Api-Key", c.UserAPIKey)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute NerdGraph request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, parseErrorResponse(c.NerdGraphURL, responseBody, resp.StatusCode)
+ }
+
+ var gqlResponse GraphQLResponse
+ if err := json.Unmarshal(responseBody, &gqlResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode GraphQL response: %w", err)
+ }
+
+ if len(gqlResponse.Errors) > 0 {
+ var errMessages []string
+ for _, gqlErr := range gqlResponse.Errors {
+ errMessages = append(errMessages, gqlErr.Message)
+ }
+ return nil, fmt.Errorf("GraphQL errors: %s", strings.Join(errMessages, "; "))
+ }
+
+ actor, ok := gqlResponse.Data["actor"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid GraphQL response: missing actor")
+ }
+
+ account, ok := actor["account"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid GraphQL response: missing account")
+ }
+
+ nrqlData, ok := account["nrql"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("invalid GraphQL response: missing nrql")
+ }
+
+ nrqlBytes, err := json.Marshal(nrqlData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal NRQL data: %w", err)
+ }
+
+ var nrqlResponse NRQLQueryResponse
+ if err := json.Unmarshal(nrqlBytes, &nrqlResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode NRQL response: %w", err)
+ }
+
+ return &nrqlResponse, nil
+}
diff --git a/pkg/integrations/newrelic/common.go b/pkg/integrations/newrelic/common.go
new file mode 100644
index 0000000000..6e5b9485c1
--- /dev/null
+++ b/pkg/integrations/newrelic/common.go
@@ -0,0 +1,44 @@
+package newrelic
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+const (
+ // US Region
+ nerdGraphAPIBaseUS = "https://api.newrelic.com/graphql"
+ metricsAPIBaseUS = "https://metric-api.newrelic.com/metric/v1"
+
+ // EU Region
+ nerdGraphAPIBaseEU = "https://api.eu.newrelic.com/graphql"
+ metricsAPIBaseEU = "https://metric-api.eu.newrelic.com/metric/v1"
+)
+
+type Account struct {
+ ID int64 `json:"id" mapstructure:"id"`
+ Name string `json:"name" mapstructure:"name"`
+}
+
+type APIError struct {
+ ErrorDetails struct {
+ Title string `json:"title"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func (e *APIError) Error() string {
+ if e.ErrorDetails.Message != "" {
+ return fmt.Sprintf("%s: %s", e.ErrorDetails.Title, e.ErrorDetails.Message)
+ }
+ return e.ErrorDetails.Title
+}
+
+func parseErrorResponse(url string, body []byte, statusCode int) error {
+ var apiErr APIError
+ if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.ErrorDetails.Title != "" {
+ return fmt.Errorf("request to %s failed: %w", url, &apiErr)
+ }
+ // Include full URL and response body for debugging 404s and other errors
+ return fmt.Errorf("request to %s failed with status %d: %s", url, statusCode, string(body))
+}
diff --git a/pkg/integrations/newrelic/example.go b/pkg/integrations/newrelic/example.go
new file mode 100644
index 0000000000..88bfc273f3
--- /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_data_on_issue.json
+var exampleDataOnIssueBytes []byte
+
+var exampleDataOnIssueOnce sync.Once
+var exampleDataOnIssue map[string]any
+
+func (t *OnIssue) ExampleData() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue)
+}
+
+//go:embed example_output_report_metric.json
+var exampleOutputReportMetricBytes []byte
+
+var exampleOutputReportMetricOnce sync.Once
+var exampleOutputReportMetric map[string]any
+
+func (c *ReportMetric) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputReportMetricOnce, exampleOutputReportMetricBytes, &exampleOutputReportMetric)
+}
+
+//go:embed example_output_run_nrql_query.json
+var exampleOutputRunNRQLQueryBytes []byte
+
+var exampleOutputRunNRQLQueryOnce sync.Once
+var exampleOutputRunNRQLQuery map[string]any
+
+func (c *RunNRQLQuery) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputRunNRQLQueryOnce, exampleOutputRunNRQLQueryBytes, &exampleOutputRunNRQLQuery)
+}
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..456e3c3ed2
--- /dev/null
+++ b/pkg/integrations/newrelic/example_data_on_issue.json
@@ -0,0 +1,8 @@
+{
+ "issueId": "12345678-abcd-efgh-ijkl-1234567890ab",
+ "title": "High CPU Usage",
+ "priority": "CRITICAL",
+ "state": "ACTIVATED",
+ "owner": "Team SRE",
+ "issueUrl": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0="
+}
\ No newline at end of file
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..fc1377d005
--- /dev/null
+++ b/pkg/integrations/newrelic/example_output_report_metric.json
@@ -0,0 +1,8 @@
+{
+ "name": "server.cpu.usage",
+ "type": "gauge",
+ "value": 75.5,
+ "timestamp": 1707552000000,
+ "intervalMs": 0,
+ "status": "202 Accepted"
+}
\ No newline at end of file
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..8f706beb0d
--- /dev/null
+++ b/pkg/integrations/newrelic/example_output_run_nrql_query.json
@@ -0,0 +1,21 @@
+{
+ "results": [
+ {
+ "count": 1523
+ }
+ ],
+ "totalResult": null,
+ "metadata": {
+ "eventTypes": [
+ "Transaction"
+ ],
+ "facets": null,
+ "messages": [],
+ "timeWindow": {
+ "begin": 1707559740000,
+ "end": 1707563340000
+ }
+ },
+ "query": "SELECT count(*) FROM Transaction SINCE 1 hour ago",
+ "accountId": "1234567"
+}
\ No newline at end of file
diff --git a/pkg/integrations/newrelic/newrelic.go b/pkg/integrations/newrelic/newrelic.go
new file mode 100644
index 0000000000..528a8e98f6
--- /dev/null
+++ b/pkg/integrations/newrelic/newrelic.go
@@ -0,0 +1,225 @@
+package newrelic
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/registry"
+)
+
+const installationInstructions = `
+To set up New Relic integration:
+
+1. **Select Region**: Choose your New Relic region (US or EU)
+2. **Provide API Keys**: You can provide one or both keys depending on which components you need.
+
+## API Keys
+
+New Relic uses two different types of API keys for different purposes:
+
+- **User API Key** (starts with NRAK-): Required for **Run NRQL Query** and **On Issue** trigger. Get it from New Relic > Account Settings > API Keys > Create User Key.
+- **License Key** (Ingest - License): Required for **Report Metric** action. Get it from New Relic > Account Settings > API Keys > Create Ingest License Key.
+
+You may provide both keys to enable all components, or just the key(s) for the components you need.
+`
+
+func init() {
+ registry.RegisterIntegrationWithWebhookHandler("newrelic", &NewRelic{}, &NewRelicWebhookHandler{})
+}
+
+type NewRelic struct{}
+
+type Configuration struct {
+ UserAPIKey string `json:"userApiKey" mapstructure:"userApiKey"`
+ LicenseKey string `json:"licenseKey" mapstructure:"licenseKey"`
+ Site string `json:"site" mapstructure:"site"`
+}
+
+type Metadata struct {
+ Accounts []Account `json:"accounts" mapstructure:"accounts"`
+}
+
+func (n *NewRelic) Name() string {
+ return "newrelic"
+}
+
+func (n *NewRelic) Label() string {
+ return "New Relic"
+}
+
+func (n *NewRelic) Icon() string {
+ return "newrelic"
+}
+
+func (n *NewRelic) Description() string {
+ return "Monitor and manage your New Relic resources"
+}
+
+func (n *NewRelic) Instructions() string {
+ return installationInstructions
+}
+
+func (n *NewRelic) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "site",
+ Label: "Region",
+ Type: configuration.FieldTypeSelect,
+ Required: true,
+ Default: "US",
+ Description: "Your New Relic data region",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "United States (US)", Value: "US"},
+ {Label: "Europe (EU)", Value: "EU"},
+ },
+ },
+ },
+ },
+ {
+ Name: "userApiKey",
+ Label: "User API Key",
+ Type: configuration.FieldTypeString,
+ Required: false,
+ Sensitive: true,
+ Description: "User API Key (NRAK-...) for NRQL queries and triggers. Get from Account Settings > API Keys.",
+ },
+ {
+ Name: "licenseKey",
+ Label: "License Key (Ingest)",
+ Type: configuration.FieldTypeString,
+ Required: false,
+ Sensitive: true,
+ Description: "Ingest License Key for metric reporting. Get from Account Settings > API Keys.",
+ },
+ }
+}
+
+func (n *NewRelic) Components() []core.Component {
+ return []core.Component{
+ &ReportMetric{},
+ &RunNRQLQuery{},
+ }
+}
+
+func (n *NewRelic) Triggers() []core.Trigger {
+ return []core.Trigger{
+ &OnIssue{},
+ }
+}
+
+func (n *NewRelic) Sync(ctx core.SyncContext) error {
+ config := Configuration{}
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ if config.UserAPIKey == "" && config.LicenseKey == "" {
+ return fmt.Errorf("at least one API key is required: provide a User API Key (for NRQL/triggers) and/or a License Key (for metrics)")
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %w", err)
+ }
+
+ // Validate and fetch accounts only when a User API Key is provided
+ if config.UserAPIKey != "" {
+ err = client.ValidateAPIKey(context.Background())
+ if err != nil {
+ return fmt.Errorf("failed to validate User API Key: %w", err)
+ }
+
+ accounts, err := client.ListAccounts(context.Background())
+ if err != nil {
+ if ctx.Logger != nil {
+ ctx.Logger.Warnf("New Relic: failed to fetch accounts: %v", err)
+ }
+ accounts = []Account{}
+ }
+
+ ctx.Integration.SetMetadata(Metadata{
+ Accounts: accounts,
+ })
+ } else {
+ // Only License Key provided — skip NerdGraph validation
+ if ctx.Logger != nil {
+ ctx.Logger.Info("New Relic: No User API Key provided, skipping NerdGraph validation and account fetching")
+ }
+ ctx.Integration.SetMetadata(Metadata{
+ Accounts: []Account{},
+ })
+ }
+
+ ctx.Integration.Ready()
+ return nil
+}
+
+func (n *NewRelic) HandleRequest(ctx core.HTTPRequestContext) {
+ // Webhooks will be handled by triggers
+}
+
+func (n *NewRelic) Cleanup(ctx core.IntegrationCleanupContext) error {
+ return nil
+}
+
+func (n *NewRelic) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (n *NewRelic) HandleAction(ctx core.IntegrationActionContext) error {
+ return nil
+}
+
+func (n *NewRelic) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) {
+ if resourceType != "account" {
+ return []core.IntegrationResource{}, nil
+ }
+
+ metadata := Metadata{}
+ if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil {
+ return nil, fmt.Errorf("failed to decode metadata: %w", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(metadata.Accounts))
+ for _, account := range metadata.Accounts {
+ resources = append(resources, core.IntegrationResource{
+ Type: "account",
+ Name: account.Name,
+ ID: fmt.Sprintf("%d", account.ID),
+ })
+ }
+
+ return resources, nil
+}
+
+type NewRelicWebhookHandler struct{}
+
+func (h *NewRelicWebhookHandler) CompareConfig(a, b any) (bool, error) {
+ if a == nil && b == nil {
+ return true, nil
+ }
+ return reflect.DeepEqual(a, b), nil
+}
+
+func (h *NewRelicWebhookHandler) Merge(prev, curr any) (any, bool, error) {
+ return curr, true, nil
+}
+
+func (h *NewRelicWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) {
+ return map[string]any{"manual": true}, nil
+}
+
+func (h *NewRelicWebhookHandler) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ trigger := &OnIssue{}
+ return trigger.HandleWebhook(ctx)
+}
+
+func (h *NewRelicWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) 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..5d56854add
--- /dev/null
+++ b/pkg/integrations/newrelic/newrelic_test.go
@@ -0,0 +1,543 @@
+package newrelic
+
+import (
+ "context"
+ "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 jsonResponse(statusCode int, body string) *http.Response {
+ return &http.Response{
+ StatusCode: statusCode,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: make(http.Header),
+ }
+}
+
+func Test__NewRelic__Sync(t *testing.T) {
+ n := &NewRelic{}
+
+ t.Run("no keys provided -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"site": "US"},
+ Integration: integrationCtx,
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one API key is required")
+ })
+
+ t.Run("empty keys -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "",
+ "licenseKey": "",
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"userApiKey": "", "licenseKey": "", "site": "US"},
+ Integration: integrationCtx,
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one API key is required")
+ })
+
+ t.Run("invalid user API key -> error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusUnauthorized, `{
+ "error": {
+ "title": "Unauthorized",
+ "message": "Invalid API key"
+ }
+ }`),
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-invalid-key",
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"userApiKey": "NRAK-invalid-key", "site": "US"},
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to validate User API Key")
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://api.newrelic.com/graphql", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ assert.Equal(t, "NRAK-invalid-key", httpCtx.Requests[0].Header.Get("Api-Key"))
+ })
+
+ t.Run("valid user API key -> validates and sets ready", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": {
+ "user": {
+ "name": "Test User",
+ "email": "test@example.com"
+ }
+ }
+ }
+ }`),
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": {
+ "accounts": [
+ {"id": 123456, "name": "Test Account"}
+ ]
+ }
+ }
+ }`),
+ },
+ }
+
+ userAPIKey := "NRAK-test-api-key"
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": userAPIKey,
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"userApiKey": userAPIKey, "site": "US"},
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "ready", integrationCtx.State)
+ require.Len(t, httpCtx.Requests, 2)
+ assert.Equal(t, "https://api.newrelic.com/graphql", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ assert.Equal(t, "NRAK-test-api-key", httpCtx.Requests[0].Header.Get("Api-Key"))
+ })
+
+ t.Run("network error with user API key -> error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{},
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-key",
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"userApiKey": "NRAK-test-key", "site": "US"},
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to validate User API Key")
+ })
+
+ t.Run("only license key -> skips validation and sets ready", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{},
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "license-key-1234567890",
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{"licenseKey": "license-key-1234567890", "site": "US"},
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "ready", integrationCtx.State)
+ require.Len(t, httpCtx.Requests, 0)
+
+ metadata, ok := integrationCtx.Metadata.(Metadata)
+ require.True(t, ok)
+ assert.Empty(t, metadata.Accounts)
+ })
+
+ t.Run("both keys -> validates user API key and sets ready", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": {
+ "user": {
+ "name": "Test User",
+ "email": "test@example.com"
+ }
+ }
+ }
+ }`),
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": {
+ "accounts": [
+ {"id": 123456, "name": "Test Account"}
+ ]
+ }
+ }
+ }`),
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-api-key",
+ "licenseKey": "license-key-12345",
+ "site": "US",
+ },
+ }
+
+ err := n.Sync(core.SyncContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-api-key",
+ "licenseKey": "license-key-12345",
+ "site": "US",
+ },
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "ready", integrationCtx.State)
+ require.Len(t, httpCtx.Requests, 2)
+ })
+}
+
+func Test__NewRelic__ListResources(t *testing.T) {
+ n := &NewRelic{}
+
+ t.Run("lists accounts from metadata", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Metadata: Metadata{
+ Accounts: []Account{
+ {ID: 123456, Name: "Test Account"},
+ {ID: 789012, Name: "Production Account"},
+ },
+ },
+ }
+
+ resources, err := n.ListResources("account", core.ListResourcesContext{
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ require.Len(t, resources, 2)
+ assert.Equal(t, "account", resources[0].Type)
+ assert.Equal(t, "123456", resources[0].ID)
+ assert.Equal(t, "Test Account", resources[0].Name)
+ assert.Equal(t, "789012", resources[1].ID)
+ assert.Equal(t, "Production Account", resources[1].Name)
+ })
+
+ t.Run("unknown resource type returns empty", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Metadata: Metadata{Accounts: []Account{}},
+ }
+
+ resources, err := n.ListResources("unknown", core.ListResourcesContext{
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Empty(t, resources)
+ })
+
+ t.Run("empty metadata returns empty", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Metadata: Metadata{Accounts: []Account{}},
+ }
+
+ resources, err := n.ListResources("account", core.ListResourcesContext{
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Empty(t, resources)
+ })
+}
+
+func Test__Client__NewClient(t *testing.T) {
+ t.Run("no keys -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "US",
+ },
+ }
+
+ _, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one API key is required")
+ })
+
+ t.Run("empty keys -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "",
+ "licenseKey": "",
+ "site": "US",
+ },
+ }
+
+ _, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one API key is required")
+ })
+
+ t.Run("user API key only -> success", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-key",
+ "site": "US",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.NotNil(t, client)
+ assert.Equal(t, "NRAK-test-key", client.UserAPIKey)
+ assert.Equal(t, "", client.LicenseKey)
+ assert.Equal(t, "https://api.newrelic.com/graphql", client.NerdGraphURL)
+ assert.Equal(t, "https://metric-api.newrelic.com/metric/v1", client.MetricBaseURL)
+ })
+
+ t.Run("license key only -> success", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "license-key-12345",
+ "site": "US",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.NotNil(t, client)
+ assert.Equal(t, "", client.UserAPIKey)
+ assert.Equal(t, "license-key-12345", client.LicenseKey)
+ assert.Equal(t, "https://api.newrelic.com/graphql", client.NerdGraphURL)
+ })
+
+ t.Run("both keys -> success", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-key",
+ "licenseKey": "license-key-12345",
+ "site": "US",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.NotNil(t, client)
+ assert.Equal(t, "NRAK-test-key", client.UserAPIKey)
+ assert.Equal(t, "license-key-12345", client.LicenseKey)
+ })
+
+ t.Run("EU region -> correct URLs", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-test-key",
+ "site": "EU",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.NotNil(t, client)
+ assert.Equal(t, "https://api.eu.newrelic.com/graphql", client.NerdGraphURL)
+ assert.Equal(t, "https://metric-api.eu.newrelic.com/metric/v1", client.MetricBaseURL)
+ })
+}
+
+func Test__Client__ValidateAPIKey(t *testing.T) {
+ t.Run("successful request -> validates successfully", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": {
+ "user": {
+ "name": "Test User",
+ "email": "test@example.com"
+ }
+ }
+ }
+ }`),
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "NRAK-test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ err := client.ValidateAPIKey(context.Background())
+
+ require.NoError(t, err)
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://api.newrelic.com/graphql", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ assert.Equal(t, "NRAK-test-key", httpCtx.Requests[0].Header.Get("Api-Key"))
+ })
+
+ t.Run("successful request with missing actor -> validates successfully", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusOK, `{
+ "data": {
+ "actor": null
+ }
+ }`),
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "NRAK-test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ err := client.ValidateAPIKey(context.Background())
+
+ require.NoError(t, err)
+ })
+
+ t.Run("long garbage key -> returns error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusUnauthorized, `{
+ "error": {
+ "title": "Unauthorized",
+ "message": "Invalid API key"
+ }
+ }`),
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: strings.Repeat("x", 40),
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ err := client.ValidateAPIKey(context.Background())
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Unauthorized")
+ require.Len(t, httpCtx.Requests, 1)
+ })
+
+ t.Run("API error -> returns error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusUnauthorized, `{
+ "error": {
+ "title": "Unauthorized",
+ "message": "Invalid API key"
+ }
+ }`),
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "invalid-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ err := client.ValidateAPIKey(context.Background())
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Unauthorized")
+ })
+
+ t.Run("GraphQL errors -> returns error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ jsonResponse(http.StatusOK, `{
+ "errors": [
+ {"message": "Invalid API key"}
+ ]
+ }`),
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "NRAK-test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ err := client.ValidateAPIKey(context.Background())
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "GraphQL errors")
+ assert.Contains(t, err.Error(), "Invalid API key")
+ })
+}
+
+func Test__NewRelic__Name(t *testing.T) {
+ integration := &NewRelic{}
+ assert.Equal(t, "newrelic", integration.Name())
+}
+
+func Test__NewRelic__Label(t *testing.T) {
+ integration := &NewRelic{}
+ assert.Equal(t, "New Relic", integration.Label())
+}
+
+func Test__NewRelic__Configuration(t *testing.T) {
+ integration := &NewRelic{}
+ config := integration.Configuration()
+ assert.NotEmpty(t, config)
+ assert.Len(t, config, 3)
+ assert.Equal(t, "site", config[0].Name)
+ assert.True(t, config[0].Required)
+ assert.Equal(t, "userApiKey", config[1].Name)
+ assert.False(t, config[1].Required)
+ assert.True(t, config[1].Sensitive)
+ assert.Equal(t, "licenseKey", config[2].Name)
+ assert.False(t, config[2].Required)
+ assert.True(t, config[2].Sensitive)
+}
diff --git a/pkg/integrations/newrelic/on_issue.go b/pkg/integrations/newrelic/on_issue.go
new file mode 100644
index 0000000000..e0b9b2cb2c
--- /dev/null
+++ b/pkg/integrations/newrelic/on_issue.go
@@ -0,0 +1,286 @@
+package newrelic
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+
+ "github.com/mitchellh/mapstructure"
+ log "github.com/sirupsen/logrus"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type OnIssue struct{}
+
+type OnIssueConfiguration struct {
+ Priorities []string `json:"priorities" yaml:"priorities" mapstructure:"priorities"`
+ States []string `json:"states" yaml:"states" mapstructure:"states"`
+}
+
+func (t *OnIssue) Name() string {
+ return "newrelic.onIssue"
+}
+
+func (t *OnIssue) Label() string {
+ return "On Issue"
+}
+
+func (t *OnIssue) Description() string {
+ return "Listen to New Relic issue events"
+}
+
+func (t *OnIssue) Documentation() string {
+ return `The On Issue trigger starts a workflow execution when New Relic issues are created or updated.
+
+## Use Cases
+
+- **Incident Response**: automated remediation or notification when critical issues occur.
+- **Sync**: synchronize New Relic issues with Jira or other tracking systems.
+
+## Configuration
+
+- **Priorities**: Filter by priority (CRITICAL, HIGH, MEDIUM, LOW). Leave empty for all.
+- **States**: Filter by state (ACTIVATED, CLOSED, CREATED). Leave empty for all.
+
+## Webhook Setup
+
+This trigger generates a webhook URL. You must configure a **Workflow** in New Relic to send a webhook to this URL.
+
+**IMPORTANT**: You must use the following JSON payload template in your New Relic Webhook configuration:
+
+` + "```json" + `
+{
+ "issue_id": "{{issueId}}",
+ "title": "{{annotations.title.[0]}}",
+ "priority": "{{priority}}",
+ "issue_url": "{{issuePageUrl}}",
+ "state": "{{state}}",
+ "owner": "{{owner}}"
+}
+` + "```" + `
+`
+}
+
+func (t *OnIssue) Icon() string {
+ return "alert-triangle"
+}
+
+func (t *OnIssue) Color() string {
+ return "teal"
+}
+
+func (t *OnIssue) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "priorities",
+ Label: "Priorities",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Description: "Filter issues by priority",
+ 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"},
+ },
+ },
+ },
+ },
+ {
+ Name: "states",
+ Label: "States",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Description: "Filter issues by state",
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Activated", Value: "ACTIVATED"},
+ {Label: "Closed", Value: "CLOSED"},
+ {Label: "Created", Value: "CREATED"},
+ },
+ },
+ },
+ },
+ }
+}
+
+// OnIssueMetadata holds the metadata stored on the canvas node for the UI.
+type OnIssueMetadata struct {
+ URL string `json:"url" mapstructure:"url"`
+ Manual bool `json:"manual" mapstructure:"manual"`
+}
+
+func (t *OnIssue) Setup(ctx core.TriggerContext) error {
+ userAPIKey, err := ctx.Integration.GetConfig("userApiKey")
+ if err != nil || len(userAPIKey) == 0 {
+ msg := "User API Key is required for this component. Please configure it in the Integration settings."
+ return fmt.Errorf("%s", msg)
+ }
+
+ // 1. Always ensure manual: true in metadata so the UI refreshes correctly
+ // to show the webhook URL once set up.
+ var metadata OnIssueMetadata
+ if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil {
+ // If decode fails, start fresh
+ metadata = OnIssueMetadata{}
+ }
+
+ metadata.Manual = true
+ if err := ctx.Metadata.Set(metadata); err != nil {
+ return fmt.Errorf("failed to set metadata: %w", err)
+ }
+
+ // 2. Check if URL is already set (idempotency guard)
+ if metadata.URL != "" {
+ return nil
+ }
+
+ // 3. Decode configuration
+ config := OnIssueConfiguration{}
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ // 4. Create the webhook and get the URL
+ webhookURL, err := ctx.Webhook.Setup()
+ if err != nil {
+ return fmt.Errorf("failed to setup webhook: %w", err)
+ }
+
+ // 5. Store the URL in node metadata
+ metadata.URL = webhookURL
+ if err := ctx.Metadata.Set(metadata); err != nil {
+ return fmt.Errorf("failed to set metadata: %w", err)
+ }
+
+ ctx.Logger.Infof("New Relic OnIssue webhook URL: %s", webhookURL)
+ return nil
+}
+
+func (t *OnIssue) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (t *OnIssue) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) {
+ return nil, nil
+}
+
+type NewRelicIssue struct {
+ IssueID string `json:"issue_id"`
+ Title string `json:"title"`
+ Priority string `json:"priority"`
+ State string `json:"state"`
+ Owner string `json:"owner"`
+ URL string `json:"issue_url"`
+}
+
+// HandleWebhook processes incoming New Relic webhook requests
+//
+// Represents the "Azure Pattern" adapted for New Relic:
+// 1. Handshake/Validation check (Test notification)
+// 2. Mapstructure decoding
+// 3. Event processing
+func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ // 1. Decode Configuration
+ config := OnIssueConfiguration{}
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ log.Errorf("Error decoding configuration: %v", err)
+ return http.StatusBadRequest, fmt.Errorf("error decoding configuration: %w", err)
+ }
+
+ // 2. Parse Payload into Map (Handshake Check)
+ var rawPayload map[string]any
+ if len(ctx.Body) == 0 {
+ log.Infof("New Relic Validation Ping Received (Empty Body)")
+ return http.StatusOK, nil
+ }
+ if err := json.Unmarshal(ctx.Body, &rawPayload); err != nil {
+ log.Errorf("Error parsing webhook body: %v", err)
+ return http.StatusBadRequest, fmt.Errorf("error parsing webhook body: %w", err)
+ }
+
+ // 3. Handshake Logic (Mirroring Azure SubscriptionValidation)
+ // If the payload is missing issue_id (indicating a New Relic "Test Connection" ping)
+ _, hasIssueID := rawPayload["issue_id"]
+
+ if !hasIssueID {
+ log.Infof("New Relic Validation Ping Received")
+ return http.StatusOK, nil
+ }
+
+ // 4. Decode Payload (Mapstructure)
+ var issue NewRelicIssue
+ decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
+ Metadata: nil,
+ Result: &issue,
+ TagName: "json",
+ })
+ if err != nil {
+ log.Errorf("Error creating decoder: %v", err)
+ return http.StatusInternalServerError, fmt.Errorf("error creating decoder: %w", err)
+ }
+
+ if err := decoder.Decode(rawPayload); err != nil {
+ log.Errorf("Error decoding payload: %v", err)
+ return http.StatusBadRequest, fmt.Errorf("error decoding payload: %w", err)
+ }
+
+ // 5. Filter Logic
+ if !allowedPriority(issue.Priority, config.Priorities) {
+ return http.StatusOK, nil
+ }
+ if !allowedState(issue.State, config.States) {
+ return http.StatusOK, nil
+ }
+
+ var eventName string
+ switch issue.State {
+ case "ACTIVATED":
+ eventName = "newrelic.issue_activated"
+ case "CLOSED":
+ eventName = "newrelic.issue_closed"
+ case "CREATED":
+ eventName = "newrelic.issue_created"
+ default:
+ eventName = "newrelic.issue_updated"
+ }
+
+ // 6. Emit Event
+ eventData := map[string]any{
+ "issueId": issue.IssueID,
+ "title": issue.Title,
+ "priority": issue.Priority,
+ "state": issue.State,
+ "issueUrl": issue.URL,
+ "owner": issue.Owner,
+ }
+
+ if err := ctx.Events.Emit(eventName, eventData); err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("failed to emit event: %w", err)
+ }
+
+ return http.StatusOK, nil
+}
+
+func allowedPriority(priority string, allowed []string) bool {
+ if len(allowed) == 0 {
+ return true
+ }
+ return slices.Contains(allowed, priority)
+}
+
+func allowedState(state string, allowed []string) bool {
+ if len(allowed) == 0 {
+ return true
+ }
+ return slices.Contains(allowed, state)
+}
+
+func (t *OnIssue) Cleanup(ctx core.TriggerContext) error {
+ return nil
+}
diff --git a/pkg/integrations/newrelic/on_issue_test.go b/pkg/integrations/newrelic/on_issue_test.go
new file mode 100644
index 0000000000..b267e88b69
--- /dev/null
+++ b/pkg/integrations/newrelic/on_issue_test.go
@@ -0,0 +1,148 @@
+package newrelic
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "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("valid configuration -> success", func(t *testing.T) {
+ metadataCtx := &contexts.MetadataContext{}
+ ctx := core.TriggerContext{
+ Configuration: map[string]any{
+ "priorities": []string{"CRITICAL"},
+ "states": []string{"ACTIVATED"},
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-TEST",
+ },
+ },
+ Webhook: &contexts.WebhookContext{},
+ Metadata: metadataCtx,
+ Logger: log.NewEntry(log.New()),
+ }
+
+ err := trigger.Setup(ctx)
+ require.NoError(t, err)
+
+ // Verify metadata was set with a URL and manual flag
+ metadata, ok := metadataCtx.Metadata.(OnIssueMetadata)
+ require.True(t, ok, "metadata should be OnIssueMetadata")
+ assert.NotEmpty(t, metadata.URL, "webhook URL should be set in metadata")
+ assert.True(t, metadata.Manual, "manual flag should be true in metadata")
+ })
+
+ t.Run("idempotent when metadata already has URL", func(t *testing.T) {
+ existingURL := "http://localhost:3000/api/v1/webhooks/existing-id"
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{"url": existingURL},
+ }
+ ctx := core.TriggerContext{
+ Configuration: map[string]any{},
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "NRAK-TEST",
+ },
+ },
+ Metadata: metadataCtx,
+ Logger: log.NewEntry(log.New()),
+ }
+
+ err := trigger.Setup(ctx)
+ require.NoError(t, err)
+ })
+}
+
+func Test__OnIssue__HandleWebhook(t *testing.T) {
+ trigger := &OnIssue{}
+
+ t.Run("filters out non-matching priority", func(t *testing.T) {
+ payload := map[string]any{
+ "issue_id": "123",
+ "priority": "LOW",
+ "state": "ACTIVATED",
+ }
+ body, _ := json.Marshal(payload)
+
+ ctx := core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "priorities": []string{"CRITICAL"},
+ },
+ Body: body,
+ Events: &contexts.EventContext{},
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+ // No event emitted
+ assert.Equal(t, 0, ctx.Events.(*contexts.EventContext).Count())
+ })
+
+ t.Run("matches and emits event", func(t *testing.T) {
+ payload := map[string]any{
+ "priority": "CRITICAL",
+ "state": "ACTIVATED",
+ "issue_id": "123",
+ "issue_url": "http://example.com/issue/123",
+ }
+ body, _ := json.Marshal(payload)
+
+ ctx := core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "priorities": []string{"CRITICAL"},
+ },
+ Body: body,
+ Events: &contexts.EventContext{},
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+
+ eventCtx := ctx.Events.(*contexts.EventContext)
+ require.Equal(t, 1, eventCtx.Count())
+ assert.Equal(t, "newrelic.issue_activated", eventCtx.Payloads[0].Type)
+
+ data, ok := eventCtx.Payloads[0].Data.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "123", data["issueId"])
+ })
+ t.Run("garbage payload -> 200 OK (no error)", func(t *testing.T) {
+ ctx := core.WebhookRequestContext{
+ Configuration: map[string]any{},
+ Body: []byte(`{"test": "notification"}`), // Simulate New Relic Test Notification
+ Events: &contexts.EventContext{},
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+ // No event emitted
+ assert.Equal(t, 0, ctx.Events.(*contexts.EventContext).Count())
+ })
+
+ t.Run("empty body -> 200 OK (ping)", func(t *testing.T) {
+ ctx := core.WebhookRequestContext{
+ Configuration: map[string]any{},
+ Body: []byte(""),
+ Events: &contexts.EventContext{},
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, status)
+ // No event emitted
+ assert.Equal(t, 0, ctx.Events.(*contexts.EventContext).Count())
+ })
+}
diff --git a/pkg/integrations/newrelic/report_metric.go b/pkg/integrations/newrelic/report_metric.go
new file mode 100644
index 0000000000..fa1d91c8c0
--- /dev/null
+++ b/pkg/integrations/newrelic/report_metric.go
@@ -0,0 +1,240 @@
+package newrelic
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const ReportMetricPayloadType = "newrelic.metric"
+
+type ReportMetric struct{}
+
+type ReportMetricSpec struct {
+ MetricName string `json:"metricName" yaml:"metricName" mapstructure:"metricName"`
+ MetricType string `json:"metricType" yaml:"metricType" mapstructure:"metricType"`
+ Value float64 `json:"value" yaml:"value" mapstructure:"value"`
+ Timestamp int64 `json:"timestamp" yaml:"timestamp" mapstructure:"timestamp"`
+ IntervalMs int64 `json:"intervalMs" yaml:"intervalMs" mapstructure:"intervalMs"`
+ Attributes map[string]any `json:"attributes" yaml:"attributes" mapstructure:"attributes"`
+}
+
+func (c *ReportMetric) Name() string {
+ return "newrelic.reportMetric"
+}
+
+func (c *ReportMetric) Label() string {
+ return "Report Metric"
+}
+
+func (c *ReportMetric) Description() string {
+ return "Send custom metrics to New Relic"
+}
+
+func (c *ReportMetric) Documentation() string {
+ return `The Report Metric component allows you to send custom metrics (Gauge, Count, Summary) to New Relic.
+
+## Configuration
+
+- **Metric Name**: The name of the metric (e.g., "server.cpu.usage")
+- **Metric Type**: The type of metric (Gauge, Count, or Summary)
+- **Value**: The numeric value of the metric
+- **Timestamp**: Optional Unix timestamp (milliseconds). Defaults to now.
+- **Interval (ms)**: Required for Count and Summary metrics. The duration of the measurement window in milliseconds.
+- **Attributes**: Optional JSON object with additional attributes
+
+## Output
+
+Returns the sent metric payload.
+`
+}
+
+func (c *ReportMetric) Icon() string {
+ return "newrelic"
+}
+
+func (c *ReportMetric) Color() string {
+ return "green"
+}
+
+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. server.cpu.usage)",
+ Placeholder: "server.cpu.usage",
+ },
+ {
+ Name: "metricType",
+ Label: "Metric Type",
+ Type: configuration.FieldTypeSelect,
+ Required: true,
+ Description: "The type of metric",
+ Default: "gauge",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Gauge", Value: "gauge"},
+ {Label: "Count", Value: "count"},
+ {Label: "Summary", Value: "summary"},
+ },
+ },
+ },
+ },
+ {
+ Name: "value",
+ Label: "Value",
+ Type: configuration.FieldTypeNumber,
+ Required: true,
+ Description: "The numeric value of the metric",
+ Placeholder: "0",
+ },
+ {
+ Name: "timestamp",
+ Label: "Timestamp (ms)",
+ Type: configuration.FieldTypeNumber,
+ Required: false,
+ Description: "Optional Unix timestamp in milliseconds. Defaults to now.",
+ },
+ {
+ Name: "intervalMs",
+ Label: "Interval (ms)",
+ Type: configuration.FieldTypeNumber,
+ Required: false,
+ Description: "Required and must be > 0 for count and summary metrics. Represents the duration of the measurement window in milliseconds.",
+ },
+ {
+ Name: "attributes",
+ Label: "Attributes",
+ Type: configuration.FieldTypeObject,
+ Required: false,
+ Description: "Optional additional attributes",
+ },
+ }
+}
+
+func (c *ReportMetric) Setup(ctx core.SetupContext) error {
+ // Metric ingestion requires a License Key (preferred)
+ licenseKey, err := ctx.Integration.GetConfig("licenseKey")
+ if err != nil || len(licenseKey) == 0 {
+ msg := "License Key is required for this component. Please configure it in the Integration settings."
+ return fmt.Errorf("%s", msg)
+ }
+
+ spec := ReportMetricSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("failed to decode configuration: %v", err)
+ }
+
+ if spec.MetricName == "" {
+ return fmt.Errorf("metricName is required")
+ }
+
+ if spec.MetricType == "" {
+ return fmt.Errorf("metricType is required")
+ }
+
+ switch spec.MetricType {
+ case string(MetricTypeGauge):
+ // no interval requirement
+ case string(MetricTypeCount), string(MetricTypeSummary):
+ if spec.IntervalMs <= 0 {
+ return fmt.Errorf("intervalMs is required and must be > 0 for count and summary metrics")
+ }
+ default:
+ return fmt.Errorf("unsupported metricType %q", spec.MetricType)
+ }
+
+ return nil
+}
+
+func (c *ReportMetric) Execute(ctx core.ExecutionContext) error {
+ spec := ReportMetricSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("failed to decode configuration: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %v", err)
+ }
+
+ timestamp := spec.Timestamp
+ if timestamp == 0 {
+ timestamp = time.Now().UnixMilli()
+ }
+
+ if (spec.MetricType == string(MetricTypeCount) || spec.MetricType == string(MetricTypeSummary)) && spec.IntervalMs <= 0 {
+ return fmt.Errorf("intervalMs is required and must be > 0 for count and summary metrics")
+ }
+
+ metric := Metric{
+ Name: spec.MetricName,
+ Type: MetricType(spec.MetricType),
+ Value: spec.Value,
+ Timestamp: timestamp,
+ IntervalMs: spec.IntervalMs,
+ Attributes: spec.Attributes,
+ }
+
+ batch := []MetricBatch{
+ {
+ Metrics: []Metric{metric},
+ },
+ }
+
+ if err := client.ReportMetric(context.Background(), batch); err != nil {
+ return fmt.Errorf("failed to report metric: %v", err)
+ }
+
+ output := map[string]any{
+ "name": metric.Name,
+ "value": metric.Value,
+ "type": metric.Type,
+ "timestamp": metric.Timestamp,
+ "intervalMs": metric.IntervalMs,
+ "status": "202 Accepted",
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ ReportMetricPayloadType,
+ []any{output},
+ )
+}
+
+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 200, nil
+}
+
+func (c *ReportMetric) Cleanup(ctx core.SetupContext) error {
+ return 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..5efc24287b
--- /dev/null
+++ b/pkg/integrations/newrelic/report_metric_test.go
@@ -0,0 +1,472 @@
+package newrelic
+
+import (
+ "context"
+ "encoding/json"
+ "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 TestReportMetric_Name(t *testing.T) {
+ component := &ReportMetric{}
+ assert.Equal(t, "newrelic.reportMetric", component.Name())
+}
+
+func TestReportMetric_Label(t *testing.T) {
+ component := &ReportMetric{}
+ assert.Equal(t, "Report Metric", component.Label())
+}
+
+func TestReportMetric_Configuration(t *testing.T) {
+ component := &ReportMetric{}
+ config := component.Configuration()
+
+ assert.NotEmpty(t, config)
+
+ // Verify required fields
+ var metricNameField, metricTypeField, valueField *bool
+ var intervalFieldFound bool
+ for _, field := range config {
+ if field.Name == "metricName" {
+ metricNameField = &field.Required
+ }
+ if field.Name == "metricType" {
+ metricTypeField = &field.Required
+ }
+ if field.Name == "value" {
+ valueField = &field.Required
+ }
+ if field.Name == "intervalMs" {
+ intervalFieldFound = true
+ }
+ }
+
+ require.NotNil(t, metricNameField)
+ assert.True(t, *metricNameField)
+ require.NotNil(t, metricTypeField)
+ assert.True(t, *metricTypeField)
+ require.NotNil(t, valueField)
+ assert.True(t, *valueField)
+ assert.True(t, intervalFieldFound, "intervalMs field should be present in configuration")
+}
+
+func TestReportMetric_Setup_IntervalValidation(t *testing.T) {
+ component := &ReportMetric{}
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "license-key-12345",
+ "site": "US",
+ },
+ }
+
+ t.Run("gauge does not require intervalMs", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "metricName": "test.metric",
+ "metricType": "gauge",
+ "value": 1.0,
+ },
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ })
+
+ t.Run("count requires intervalMs", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "metricName": "test.metric",
+ "metricType": "count",
+ "value": 1.0,
+ // intervalMs missing
+ },
+ Integration: integrationCtx,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "intervalMs is required")
+ })
+
+ t.Run("summary requires intervalMs", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "metricName": "test.metric",
+ "metricType": "summary",
+ "value": 1.0,
+ // intervalMs missing
+ },
+ Integration: integrationCtx,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "intervalMs is required")
+ })
+
+ t.Run("count with intervalMs passes validation", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "metricName": "test.metric",
+ "metricType": "count",
+ "value": 1.0,
+ "intervalMs": 60000,
+ },
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ })
+
+ t.Run("no keys provided -> error", func(t *testing.T) {
+ emptyCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "US",
+ },
+ }
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "metricName": "test.metric",
+ "metricType": "gauge",
+ "value": 1.0,
+ },
+ Integration: emptyCtx,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "License Key is required")
+ })
+}
+
+func TestClient_ReportMetric(t *testing.T) {
+ t.Run("successful request -> reports metric", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"123"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ LicenseKey: "test-key",
+ MetricBaseURL: "https://metric-api.newrelic.com/metric/v1",
+ http: httpCtx,
+ }
+
+ batch := []MetricBatch{
+ {
+ Metrics: []Metric{
+ {
+ Name: "test.metric",
+ Type: MetricTypeGauge,
+ Value: 42.5,
+ Attributes: map[string]any{
+ "host": "server1",
+ },
+ },
+ },
+ },
+ }
+
+ err := client.ReportMetric(context.Background(), batch)
+
+ require.NoError(t, err)
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://metric-api.newrelic.com/metric/v1", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ // test-key is not an NRAK key, so it should use X-License-Key
+ assert.Equal(t, "", httpCtx.Requests[0].Header.Get("Api-Key"))
+ assert.Equal(t, "test-key", httpCtx.Requests[0].Header.Get("X-License-Key"))
+ assert.Equal(t, "application/json", httpCtx.Requests[0].Header.Get("Content-Type"))
+
+ // Verify request body
+ bodyBytes, _ := io.ReadAll(httpCtx.Requests[0].Body)
+ var sentBatch []MetricBatch
+ err = json.Unmarshal(bodyBytes, &sentBatch)
+ require.NoError(t, err)
+ require.Len(t, sentBatch, 1)
+ require.Len(t, sentBatch[0].Metrics, 1)
+ assert.Equal(t, "test.metric", sentBatch[0].Metrics[0].Name)
+ assert.Equal(t, MetricTypeGauge, sentBatch[0].Metrics[0].Type)
+ assert.Equal(t, float64(42.5), sentBatch[0].Metrics[0].Value)
+ })
+
+ t.Run("fallback to UserAPIKey when no LicenseKey -> uses Api-Key header", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"123"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "NRAK-test-key",
+ MetricBaseURL: "https://metric-api.newrelic.com/metric/v1",
+ http: httpCtx,
+ }
+
+ batch := []MetricBatch{
+ {
+ Metrics: []Metric{
+ {
+ Name: "test.metric",
+ Type: MetricTypeGauge,
+ Value: 42.5,
+ },
+ },
+ },
+ }
+
+ err := client.ReportMetric(context.Background(), batch)
+
+ require.NoError(t, err)
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://metric-api.newrelic.com/metric/v1", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ assert.Equal(t, "NRAK-test-key", httpCtx.Requests[0].Header.Get("Api-Key"))
+ assert.Equal(t, "", httpCtx.Requests[0].Header.Get("X-License-Key"))
+ assert.Equal(t, "application/json", httpCtx.Requests[0].Header.Get("Content-Type"))
+ })
+
+ t.Run("successful request with common attributes -> reports metric", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"123"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ LicenseKey: "test-key",
+ MetricBaseURL: "https://metric-api.newrelic.com/metric/v1",
+ http: httpCtx,
+ }
+
+ common := map[string]any{"app": "test-app"}
+ batch := []MetricBatch{
+ {
+ Common: &common,
+ Metrics: []Metric{
+ {
+ Name: "test.metric",
+ Type: MetricTypeGauge,
+ Value: 42.5,
+ },
+ },
+ },
+ }
+
+ err := client.ReportMetric(context.Background(), batch)
+
+ require.NoError(t, err)
+
+ // Verify request body contains common attributes
+ bodyBytes, _ := io.ReadAll(httpCtx.Requests[0].Body)
+ var sentBatch []MetricBatch
+ err = json.Unmarshal(bodyBytes, &sentBatch)
+ require.NoError(t, err)
+ require.NotNil(t, sentBatch[0].Common)
+ assert.Equal(t, "test-app", (*sentBatch[0].Common)["app"])
+ })
+
+ t.Run("API error -> returns error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(strings.NewReader(`{"error":{"title":"Bad Request","message":"Invalid metric format"}}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ LicenseKey: "test-key",
+ MetricBaseURL: "https://metric-api.newrelic.com/metric/v1",
+ http: httpCtx,
+ }
+
+ batch := []MetricBatch{
+ {
+ Metrics: []Metric{
+ {
+ Name: "test.metric",
+ Type: MetricTypeGauge,
+ Value: 42.5,
+ },
+ },
+ },
+ }
+
+ err := client.ReportMetric(context.Background(), batch)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Bad Request")
+ })
+
+ t.Run("EU region -> uses EU endpoint", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"456"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ LicenseKey: "eu-test-key",
+ MetricBaseURL: "https://metric-api.eu.newrelic.com/metric/v1",
+ http: httpCtx,
+ }
+
+ batch := []MetricBatch{
+ {
+ Metrics: []Metric{
+ {
+ Name: "eu.test.metric",
+ Type: MetricTypeCount,
+ Value: 100,
+ },
+ },
+ },
+ }
+
+ err := client.ReportMetric(context.Background(), batch)
+
+ require.NoError(t, err)
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://metric-api.eu.newrelic.com/metric/v1", httpCtx.Requests[0].URL.String())
+ })
+}
+
+func TestReportMetric_Execute_SetsIntervalMsForCountAndSummary(t *testing.T) {
+ component := &ReportMetric{}
+
+ t.Run("count metric includes interval.ms in payload", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusAccepted,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"abc"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ execState := &contexts.ExecutionStateContext{}
+ execCtx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "metricName": "test.count",
+ "metricType": "count",
+ "value": 5.0,
+ "intervalMs": 60000,
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: execState,
+ }
+
+ err := component.Execute(execCtx)
+
+ require.NoError(t, err)
+ require.Len(t, httpCtx.Requests, 1)
+
+ bodyBytes, _ := io.ReadAll(httpCtx.Requests[0].Body)
+ var sentBatch []MetricBatch
+ err = json.Unmarshal(bodyBytes, &sentBatch)
+ require.NoError(t, err)
+ require.Len(t, sentBatch, 1)
+ require.Len(t, sentBatch[0].Metrics, 1)
+ assert.Equal(t, int64(60000), sentBatch[0].Metrics[0].IntervalMs)
+ assert.Equal(t, MetricTypeCount, sentBatch[0].Metrics[0].Type)
+ })
+
+ t.Run("summary metric requires intervalMs at execute time", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusAccepted,
+ Body: io.NopCloser(strings.NewReader(`{"requestId":"abc"}`)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ execCtx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "metricName": "test.summary",
+ "metricType": "summary",
+ "value": 5.0,
+ // missing intervalMs
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ }
+
+ err := component.Execute(execCtx)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "intervalMs is required")
+ })
+}
+
+func TestNewClient_MetricBaseURL(t *testing.T) {
+ t.Run("US region -> sets US metric URL", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "https://metric-api.newrelic.com/metric/v1", client.MetricBaseURL)
+ })
+
+ t.Run("EU region -> sets EU metric URL", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "licenseKey": "test-key",
+ "site": "EU",
+ },
+ }
+
+ client, err := NewClient(&contexts.HTTPContext{}, integrationCtx)
+
+ require.NoError(t, err)
+ assert.Equal(t, "https://metric-api.eu.newrelic.com/metric/v1", client.MetricBaseURL)
+ })
+}
diff --git a/pkg/integrations/newrelic/repro_test.go b/pkg/integrations/newrelic/repro_test.go
new file mode 100644
index 0000000000..7e324d9b13
--- /dev/null
+++ b/pkg/integrations/newrelic/repro_test.go
@@ -0,0 +1,77 @@
+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 TestRunNRQLQuery_Setup_Repro(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ testCases := []struct {
+ name string
+ configuration map[string]any
+ expectError bool
+ }{
+ {
+ name: "raw string id",
+ configuration: map[string]any{
+ "account": "12345",
+ "query": "SELECT count(*) FROM Transaction",
+ "timeout": 10,
+ },
+ expectError: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ accountsJSON := `{
+ "data": {
+ "actor": {
+ "accounts": [
+ {"id": 12345, "name": "Test Account"}
+ ]
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(accountsJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ ctx := core.SetupContext{
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ Configuration: tc.configuration,
+ Metadata: &contexts.MetadataContext{},
+ }
+ err := component.Setup(ctx)
+ if tc.expectError {
+ require.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/integrations/newrelic/run_nrql_query.go b/pkg/integrations/newrelic/run_nrql_query.go
new file mode 100644
index 0000000000..c99ce92db7
--- /dev/null
+++ b/pkg/integrations/newrelic/run_nrql_query.go
@@ -0,0 +1,373 @@
+package newrelic
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const RunNRQLQueryPayloadType = "newrelic.nrqlQuery"
+
+type RunNRQLQuery struct{}
+
+// RunNRQLQuerySpec defines the configuration for the Run NRQL Query component.
+type RunNRQLQuerySpec struct {
+ Account string `json:"account" mapstructure:"account"`
+ Query string `json:"query" mapstructure:"query"`
+ Timeout int `json:"timeout" mapstructure:"timeout"`
+}
+
+type RunNRQLQueryPayload struct {
+ Results []map[string]interface{} `json:"results" mapstructure:"results"`
+ TotalResult map[string]interface{} `json:"totalResult,omitempty" mapstructure:"totalResult"`
+ Metadata *NRQLMetadata `json:"metadata,omitempty" mapstructure:"metadata"`
+ Query string `json:"query" mapstructure:"query"`
+ AccountID string `json:"accountId" mapstructure:"accountId"`
+}
+
+// RunNRQLQueryNodeMetadata stores verified account details in the node metadata.
+type RunNRQLQueryNodeMetadata struct {
+ Account *Account `json:"account" mapstructure:"account"`
+ Manual bool `json:"manual" mapstructure:"manual"`
+}
+
+func (c *RunNRQLQuery) Name() string {
+ return "newrelic.runNRQLQuery"
+}
+
+func (c *RunNRQLQuery) Label() string {
+ return "Run NRQL Query"
+}
+
+func (c *RunNRQLQuery) Description() string {
+ return "Execute NRQL queries to retrieve data from New Relic"
+}
+
+func (c *RunNRQLQuery) Documentation() string {
+ return `The Run NRQL Query component allows you to execute NRQL queries via New Relic's NerdGraph API.
+
+## Use Cases
+
+- **Data retrieval**: Query telemetry data, metrics, events, and logs
+- **Custom analytics**: Build custom analytics and reporting workflows
+- **Monitoring**: Retrieve monitoring data for downstream processing
+- **Alerting**: Query data to make decisions in workflow logic
+
+## Configuration
+
+- **Account**: The New Relic account to query (select from dropdown)
+- **Query**: The NRQL query string to execute (required)
+- **Timeout**: Query timeout in seconds (optional, default: 10, max: 120)
+
+## Output
+
+Returns query results including:
+- **results**: Array of query result objects
+- **totalResult**: Aggregated result for queries with aggregation functions
+- **metadata**: Query metadata (event types, facets, messages, time window)
+- **query**: The original NRQL query executed
+- **accountId**: The account ID queried
+
+## Example Queries
+
+- Count transactions: ` + "`SELECT count(*) FROM Transaction SINCE 1 hour ago`" + `
+- Average response time: ` + "`SELECT average(duration) FROM Transaction SINCE 1 day ago`" + `
+- Faceted query: ` + "`SELECT count(*) FROM Transaction FACET appName SINCE 1 hour ago`" + `
+
+## Notes
+
+- Requires a valid New Relic API key with query permissions
+- Queries are subject to New Relic's NRQL query limits
+- Invalid NRQL syntax will return an error from the API`
+}
+
+func (c *RunNRQLQuery) Icon() string {
+ return "newrelic"
+}
+
+func (c *RunNRQLQuery) Color() string {
+ return "green"
+}
+
+func (c *RunNRQLQuery) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *RunNRQLQuery) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "account",
+ Label: "Account",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The New Relic account to query",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "account",
+ },
+ },
+ },
+ {
+ 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,
+ Description: "Query timeout in seconds (default: 10, max: 120)",
+ Default: 10,
+ Placeholder: "10",
+ },
+ }
+}
+
+func (c *RunNRQLQuery) Setup(ctx core.SetupContext) error {
+ // NRQL queries require a User API Key (NRAK-) for NerdGraph access
+ userAPIKey, err := ctx.Integration.GetConfig("userApiKey")
+ if err != nil || len(userAPIKey) == 0 {
+ msg := "User API Key is required for this component. Please configure it in the Integration settings."
+ return fmt.Errorf("%s", msg)
+ }
+
+ spec := RunNRQLQuerySpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("failed to decode configuration: %v", err)
+ }
+
+ accountIDStr := spec.Account
+
+ if accountIDStr == "" {
+ return fmt.Errorf("account is required (select from dropdown)")
+ }
+
+ // Guard: reject unresolved template tags early
+ if isUnresolvedTemplate(accountIDStr) {
+ return fmt.Errorf("account ID contains unresolved template variable: %s — configure the upstream trigger first", accountIDStr)
+ }
+
+ if spec.Query == "" {
+ return fmt.Errorf("query is required")
+ }
+
+ if isUnresolvedTemplate(spec.Query) {
+ return fmt.Errorf("query contains unresolved template variable: %s — configure the upstream trigger first", spec.Query)
+ }
+
+ // Validate timeout if provided
+ if spec.Timeout < 0 || spec.Timeout > 120 {
+ return fmt.Errorf("timeout must be between 0 and 120 seconds")
+ }
+
+ //
+ // Integration Resource Validation
+ //
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %v", err)
+ }
+
+ accounts, err := client.ListAccounts(context.Background())
+ if err != nil {
+ return fmt.Errorf("failed to list accounts: %v", err)
+ }
+
+ var verifiedAccount *Account
+ for _, acc := range accounts {
+ if strconv.FormatInt(acc.ID, 10) == strings.TrimSpace(accountIDStr) {
+ verifiedAccount = &acc
+ break
+ }
+ }
+
+ if verifiedAccount == nil {
+ return fmt.Errorf("account ID %s not found or not accessible with the provided API key", accountIDStr)
+ }
+
+ // Persist verified details to metadata
+ metadata := RunNRQLQueryNodeMetadata{
+ Account: verifiedAccount,
+ Manual: true,
+ }
+
+ if err := ctx.Metadata.Set(metadata); err != nil {
+ return fmt.Errorf("failed to set metadata: %w", err)
+ }
+
+ return nil
+}
+
+func (c *RunNRQLQuery) Execute(ctx core.ExecutionContext) error {
+ spec := RunNRQLQuerySpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("failed to decode configuration: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %v", err)
+ }
+
+ // Extract account ID from configuration
+ accountIDStr := spec.Account
+
+ // Fallback: try to resolve from upstream trigger event data (ctx.Data)
+ if accountIDStr == "" {
+ accountIDStr = extractStringFromData(ctx.Data, "accountId", "account_id", "account")
+ }
+
+ query := spec.Query
+ if query == "" {
+ query = extractStringFromData(ctx.Data, "query", "nrqlQuery")
+ }
+
+ // Guard: reject unresolved template tags — don't waste an API call
+ if isUnresolvedTemplate(accountIDStr) {
+ return fmt.Errorf("account ID contains unresolved template variable: %s — ensure the upstream trigger is configured and variables are mapped", accountIDStr)
+ }
+ if isUnresolvedTemplate(query) {
+ return fmt.Errorf("query contains unresolved template variable: %s — ensure the upstream trigger is configured and variables are mapped", query)
+ }
+
+ if accountIDStr == "" {
+ return fmt.Errorf("account ID is missing — set it in configuration or connect an upstream trigger that provides it")
+ }
+
+ if query == "" {
+ return fmt.Errorf("NRQL query is missing — set it in configuration or connect an upstream trigger that provides it")
+ }
+
+ // Parse account ID to int64 for the NerdGraph API call
+ accountID, err := strconv.ParseInt(strings.TrimSpace(accountIDStr), 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid account ID '%s': must be a numeric string (e.g. '1234567')", accountIDStr)
+ }
+
+ // Set default timeout if not provided
+ timeout := spec.Timeout
+ if timeout == 0 {
+ timeout = 10
+ }
+
+ // Execute NRQL query via NerdGraph
+ response, err := client.RunNRQLQuery(context.Background(), accountID, query, timeout)
+ if err != nil {
+ return fmt.Errorf("failed to execute NRQL query: %v", err)
+ }
+
+ payload := RunNRQLQueryPayload{
+ Results: response.Results,
+ TotalResult: response.TotalResult,
+ Metadata: response.Metadata,
+ Query: query,
+ AccountID: accountIDStr,
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ RunNRQLQueryPayloadType,
+ []any{payload},
+ )
+}
+
+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
+}
+
+// isUnresolvedTemplate detects raw template tags like {{account_id}} that
+// haven't been substituted by the platform engine. Calling the API with
+// these would always fail, so we intercept them early.
+func isUnresolvedTemplate(s string) bool {
+ return strings.Contains(s, "{{") && strings.Contains(s, "}}")
+}
+
+// extractStringFromData attempts to read a string value from upstream trigger
+// event data (ctx.Data) by trying each key in order. Returns "" if nothing found.
+func extractStringFromData(data any, keys ...string) string {
+ if data == nil {
+ return ""
+ }
+
+ m, ok := data.(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ for _, key := range keys {
+ if val, exists := m[key]; exists && val != nil {
+ return extractResourceID(val)
+ }
+ }
+
+ return ""
+}
+
+func extractResourceID(v any) string {
+ if v == nil {
+ return ""
+ }
+
+ // Handle raw string
+ if s, ok := v.(string); ok {
+ return s
+ }
+
+ // Handle raw numbers (int, float, etc.)
+ switch n := v.(type) {
+ case int:
+ return strconv.Itoa(n)
+ case int64:
+ return strconv.FormatInt(n, 10)
+ case float64:
+ return strconv.FormatFloat(n, 'f', -1, 64)
+ case float32:
+ return strconv.FormatFloat(float64(n), 'f', -1, 32)
+ }
+
+ // Handle maps
+ if m, ok := v.(map[string]any); ok {
+ // Keys to check in order of preference
+ keys := []string{"id", "ID", "value", "Value", "accountId", "account"}
+
+ for _, key := range keys {
+ if val, exists := m[key]; exists && val != nil {
+ return extractResourceID(val) // Recursively extract from the found value
+ }
+ }
+ }
+
+ // Fallback to string representation
+ return fmt.Sprintf("%v", v)
+}
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..dfb7e45c78
--- /dev/null
+++ b/pkg/integrations/newrelic/run_nrql_query_test.go
@@ -0,0 +1,769 @@
+package newrelic
+
+import (
+ "context"
+ "encoding/json"
+ "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 TestRunNRQLQuery_Name(t *testing.T) {
+ component := &RunNRQLQuery{}
+ assert.Equal(t, "newrelic.runNRQLQuery", component.Name())
+}
+
+func TestRunNRQLQuery_Label(t *testing.T) {
+ component := &RunNRQLQuery{}
+ assert.Equal(t, "Run NRQL Query", component.Label())
+}
+
+func TestRunNRQLQuery_Configuration(t *testing.T) {
+ component := &RunNRQLQuery{}
+ config := component.Configuration()
+
+ assert.NotEmpty(t, config)
+
+ // Verify required fields
+ var accountIDField, queryField *bool
+ for _, field := range config {
+ if field.Name == "account" {
+ accountIDField = &field.Required
+ }
+ if field.Name == "query" {
+ queryField = &field.Required
+ }
+ }
+
+ require.NotNil(t, accountIDField)
+ assert.True(t, *accountIDField) // account is now required
+ require.NotNil(t, queryField)
+ assert.True(t, *queryField)
+}
+
+func TestClient_RunNRQLQuery_Success(t *testing.T) {
+ t.Run("successful query -> returns results", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [
+ {
+ "count": 1523
+ }
+ ],
+ "metadata": {
+ "eventTypes": ["Transaction"],
+ "messages": [],
+ "timeWindow": {
+ "begin": 1707559740000,
+ "end": 1707563340000
+ }
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT count(*) FROM Transaction SINCE 1 hour ago", 10)
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.Len(t, response.Results, 1)
+ assert.Equal(t, float64(1523), response.Results[0]["count"])
+ require.NotNil(t, response.Metadata)
+ assert.Equal(t, []string{"Transaction"}, response.Metadata.EventTypes)
+ assert.Equal(t, int64(1707559740000), response.Metadata.TimeWindow.Begin)
+
+ // Verify request
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://api.newrelic.com/graphql", httpCtx.Requests[0].URL.String())
+ assert.Equal(t, http.MethodPost, httpCtx.Requests[0].Method)
+ assert.Equal(t, "test-key", httpCtx.Requests[0].Header.Get("Api-Key"))
+ assert.Equal(t, "application/json", httpCtx.Requests[0].Header.Get("Content-Type"))
+
+ // Verify GraphQL query structure
+ bodyBytes, _ := io.ReadAll(httpCtx.Requests[0].Body)
+ var gqlRequest GraphQLRequest
+ err = json.Unmarshal(bodyBytes, &gqlRequest)
+ require.NoError(t, err)
+ assert.Contains(t, gqlRequest.Query, "account(id: 1234567)")
+ assert.Contains(t, gqlRequest.Query, "timeout: 10")
+ assert.Contains(t, gqlRequest.Query, "SELECT count(*) FROM Transaction SINCE 1 hour ago")
+ })
+
+ t.Run("query with totalResult -> returns aggregated result", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [
+ {
+ "average": 123.45
+ }
+ ],
+ "totalResult": {
+ "average": 123.45
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT average(duration) FROM Transaction", 10)
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.NotNil(t, response.TotalResult)
+ assert.Equal(t, float64(123.45), response.TotalResult["average"])
+ })
+
+ t.Run("empty results -> returns empty array", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": []
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT * FROM NonExistent", 10)
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ assert.Empty(t, response.Results)
+ })
+
+ t.Run("EU region -> uses EU endpoint", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [{"count": 100}]
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "eu-test-key",
+ NerdGraphURL: "https://api.eu.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 7654321, "SELECT count(*) FROM Transaction", 10)
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "https://api.eu.newrelic.com/graphql", httpCtx.Requests[0].URL.String())
+ })
+}
+
+func TestClient_RunNRQLQuery_Errors(t *testing.T) {
+ t.Run("invalid NRQL syntax -> returns GraphQL error", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": null
+ }
+ }
+ },
+ "errors": [
+ {
+ "message": "NRQL Syntax Error: Error at line 1 position 8, unexpected 'FORM'",
+ "path": ["actor", "account", "nrql"]
+ }
+ ]
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT * FORM Transaction", 10)
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "GraphQL errors")
+ assert.Contains(t, err.Error(), "NRQL Syntax Error")
+ })
+
+ t.Run("authentication error -> returns error", func(t *testing.T) {
+ responseJSON := `{
+ "error": {
+ "title": "Unauthorized",
+ "message": "Invalid API key"
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusUnauthorized,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "invalid-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT count(*) FROM Transaction", 10)
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "Unauthorized")
+ })
+
+ t.Run("multiple GraphQL errors -> returns all errors", func(t *testing.T) {
+ responseJSON := `{
+ "errors": [
+ {
+ "message": "First error"
+ },
+ {
+ "message": "Second error"
+ }
+ ]
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "INVALID QUERY", 10)
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "First error")
+ assert.Contains(t, err.Error(), "Second error")
+ })
+
+ t.Run("malformed response -> returns error", func(t *testing.T) {
+ responseJSON := `{invalid json`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT count(*) FROM Transaction", 10)
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "failed to decode GraphQL response")
+ })
+
+ t.Run("missing actor in response -> returns error", func(t *testing.T) {
+ responseJSON := `{
+ "data": {}
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ client := &Client{
+ UserAPIKey: "test-key",
+ NerdGraphURL: "https://api.newrelic.com/graphql",
+ http: httpCtx,
+ }
+
+ response, err := client.RunNRQLQuery(context.Background(), 1234567, "SELECT count(*) FROM Transaction", 10)
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "missing actor")
+ })
+}
+
+func TestRunNRQLQuery_Setup(t *testing.T) {
+ t.Run("valid configuration -> no error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ accountsJSON := `{
+ "data": {
+ "actor": {
+ "accounts": [
+ {"id": 1234567, "name": "Main Account"}
+ ]
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(accountsJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ ctx := core.SetupContext{
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT count(*) FROM Transaction",
+ "timeout": 10,
+ },
+ Metadata: &contexts.MetadataContext{},
+ }
+
+ err := component.Setup(ctx)
+ assert.NoError(t, err)
+
+ // Verify metadata was set
+ metadata := ctx.Metadata.Get().(RunNRQLQueryNodeMetadata)
+ assert.True(t, metadata.Manual)
+ assert.NotNil(t, metadata.Account)
+ assert.Equal(t, int64(1234567), metadata.Account.ID)
+ })
+
+ t.Run("missing account -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+ ctx := core.SetupContext{
+ Configuration: map[string]any{
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ Metadata: &contexts.MetadataContext{},
+ }
+
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "account is required")
+ })
+
+ t.Run("missing query -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+ ctx := core.SetupContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ Metadata: &contexts.MetadataContext{},
+ }
+
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "query is required")
+ })
+
+ t.Run("invalid timeout -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+ ctx := core.SetupContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT count(*) FROM Transaction",
+ "timeout": 150, // exceeds max of 120
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ Metadata: &contexts.MetadataContext{},
+ }
+
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "timeout must be between 0 and 120")
+ })
+
+ t.Run("account not found -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ accountsJSON := `{
+ "data": {
+ "actor": {
+ "accounts": [
+ {"id": 9999999, "name": "Other Account"}
+ ]
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(accountsJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ ctx := core.SetupContext{
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Metadata: &contexts.MetadataContext{},
+ }
+
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "account ID 1234567 not found")
+ })
+}
+
+func TestRunNRQLQuery_Setup_TemplateGuard(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ t.Run("unresolved account template -> error", func(t *testing.T) {
+ ctx := core.SetupContext{
+ Configuration: map[string]any{
+ "account": "{{account_id}}",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Integration: integrationCtx,
+ Metadata: &contexts.MetadataContext{},
+ }
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unresolved template variable")
+ assert.Contains(t, err.Error(), "{{account_id}}")
+ })
+
+ t.Run("unresolved query template -> error", func(t *testing.T) {
+ ctx := core.SetupContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "{{nrql_query}}",
+ },
+ Integration: integrationCtx,
+ Metadata: &contexts.MetadataContext{},
+ }
+ err := component.Setup(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unresolved template variable")
+ assert.Contains(t, err.Error(), "{{nrql_query}}")
+ })
+}
+
+func TestRunNRQLQuery_Execute_TemplateGuard(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ t.Run("unresolved account_id template -> error, no API call", func(t *testing.T) {
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "{{account_id}}",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ }
+ err := component.Execute(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unresolved template variable")
+ assert.Contains(t, err.Error(), "{{account_id}}")
+ })
+
+ t.Run("unresolved query template -> error, no API call", func(t *testing.T) {
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "{{nrql_query}}",
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ }
+ err := component.Execute(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unresolved template variable")
+ })
+}
+
+func TestRunNRQLQuery_Execute_DataFallback(t *testing.T) {
+ t.Run("account from ctx.Data fallback -> success", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [{"count": 42}]
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{}
+
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ // account is intentionally missing from config
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Data: map[string]any{
+ "accountId": "7654321",
+ },
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ ExecutionState: executionState,
+ }
+
+ err := component.Execute(ctx)
+ require.NoError(t, err)
+ require.Len(t, executionState.Payloads, 1)
+ payloadMap := executionState.Payloads[0].(map[string]any)
+ payload := payloadMap["data"].(RunNRQLQueryPayload)
+ assert.Equal(t, "7654321", payload.AccountID)
+ })
+}
+
+func TestRunNRQLQuery_Execute(t *testing.T) {
+ t.Run("string account ID -> success", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [{"count": 10}]
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{}
+
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: executionState,
+ }
+
+ err := component.Execute(ctx)
+ require.NoError(t, err)
+
+ // Verify emission
+ require.Len(t, executionState.Payloads, 1)
+ payloadMap := executionState.Payloads[0].(map[string]any)
+ payload := payloadMap["data"].(RunNRQLQueryPayload)
+ assert.Equal(t, "1234567", payload.AccountID)
+ assert.Equal(t, "SELECT count(*) FROM Transaction", payload.Query)
+ })
+
+ t.Run("invalid account ID string -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "not-a-number",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ }
+
+ err := component.Execute(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid account ID 'not-a-number'")
+ })
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 40c4986339..dbfc4944fc 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -41,6 +41,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/discord"
_ "github.com/superplanehq/superplane/pkg/integrations/github"
_ "github.com/superplanehq/superplane/pkg/integrations/jira"
+ _ "github.com/superplanehq/superplane/pkg/integrations/newrelic"
_ "github.com/superplanehq/superplane/pkg/integrations/openai"
_ "github.com/superplanehq/superplane/pkg/integrations/pagerduty"
_ "github.com/superplanehq/superplane/pkg/integrations/rootly"