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/Newrelic.mdx b/docs/components/Newrelic.mdx
new file mode 100644
index 0000000000..9737743a66
--- /dev/null
+++ b/docs/components/Newrelic.mdx
@@ -0,0 +1,193 @@
+---
+title: "Newrelic"
+---
+
+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 and a Secret. You must configure a **Workflow** in New Relic to send a webhook to this URL.
+
+**IMPORTANT**: You must add a custom header `X-Superplane-Secret` in your New Relic Webhook configuration, using the Secret provided by Superplane.
+
+**Payload**:
+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
+{
+ "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab",
+ "issue_url": "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",
+ "statusCode": 202,
+ "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)
+
+### How It Works
+
+Queries use New Relic's asynchronous query API:
+
+1. The query is submitted via NerdGraph.
+2. If results are available within the internal API timeout, they are emitted immediately.
+3. If the query takes longer, the component polls for results automatically.
+4. Polling intervals respect the "Retry-After" suggestion from New Relic API, falling back to 10 seconds if not provided.
+
+### 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..5bec165e11
--- /dev/null
+++ b/pkg/integrations/newrelic/client.go
@@ -0,0 +1,345 @@
+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)
+ }
+
+ siteStr := strings.TrimSpace(string(site))
+
+ var nerdGraphURL, metricBaseURL string
+ if siteStr == "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"`
+}
+
+type ReportMetricResult struct {
+ StatusCode int `json:"statusCode"`
+ Status string `json:"status"`
+}
+
+func (c *Client) ReportMetric(ctx context.Context, batch []MetricBatch) (*ReportMetricResult, error) {
+ url := c.MetricBaseURL
+
+ bodyBytes, err := json.Marshal(batch)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal metric batch: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return nil, 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 nil, 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 nil, parseErrorResponse(url, body, resp.StatusCode)
+ }
+
+ return &ReportMetricResult{
+ StatusCode: resp.StatusCode,
+ Status: resp.Status,
+ }, nil
+}
+
+// doNerdGraphRequest handles the shared boilerplate for all GraphQL calls to Newrelic
+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"`
+ QueryProgress *QueryProgress `json:"queryProgress,omitempty"`
+}
+
+type QueryProgress struct {
+ QueryId string `json:"queryId"`
+ Completed bool `json:"completed"`
+ RetryAfter int `json:"retryAfter"`
+ RetryDeadline int64 `json:"retryDeadline"`
+ ResultExpiration int64 `json:"resultExpiration"`
+}
+
+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"`
+}
+
+// RunNRQLQuery executes an async NRQL query via NerdGraph with a fixed 10s timeout.
+// If the query completes within 10s, results are returned directly.
+// Otherwise, QueryProgress is populated with a queryId for polling.
+func (c *Client) RunNRQLQuery(ctx context.Context, accountID int64, query string) (*NRQLQueryResponse, error) {
+ graphqlQuery := fmt.Sprintf(`{
+ actor {
+ account(id: %d) {
+ nrql(query: %s, timeout: 10, async: true) {
+ results
+ totalResult
+ metadata {
+ eventTypes
+ facets
+ messages
+ timeWindow {
+ begin
+ end
+ }
+ }
+ queryProgress {
+ queryId
+ completed
+ retryAfter
+ retryDeadline
+ resultExpiration
+ }
+ }
+ }
+ }
+ }`, accountID, strconv.Quote(query))
+
+ return c.executeNRQLGraphQL(ctx, graphqlQuery)
+}
+
+// PollNRQLQuery polls for the result of an async NRQL query using the queryId.
+func (c *Client) PollNRQLQuery(ctx context.Context, accountID int64, queryId string) (*NRQLQueryResponse, error) {
+ graphqlQuery := fmt.Sprintf(`{
+ actor {
+ account(id: %d) {
+ nrqlQueryProgress(queryId: %s) {
+ results
+ totalResult
+ metadata {
+ eventTypes
+ facets
+ messages
+ timeWindow {
+ begin
+ end
+ }
+ }
+ queryProgress {
+ queryId
+ completed
+ retryAfter
+ retryDeadline
+ resultExpiration
+ }
+ }
+ }
+ }
+ }`, accountID, strconv.Quote(queryId))
+
+ return c.executeNRQLGraphQL(ctx, graphqlQuery)
+}
+
+// nrqlGraphQLData is used to deserialize the GraphQL response from doNerdGraphRequest.
+type nrqlGraphQLData struct {
+ Actor struct {
+ Account struct {
+ NRQL *NRQLQueryResponse `json:"nrql,omitempty"`
+ NRQLQueryProgress *NRQLQueryResponse `json:"nrqlQueryProgress,omitempty"`
+ } `json:"account"`
+ } `json:"actor"`
+}
+
+// executeNRQLGraphQL is a shared helper for NRQL query and poll requests.
+// It delegates HTTP/GraphQL boilerplate to doNerdGraphRequest.
+func (c *Client) executeNRQLGraphQL(ctx context.Context, graphqlQuery string) (*NRQLQueryResponse, error) {
+ var data nrqlGraphQLData
+ if err := c.doNerdGraphRequest(ctx, graphqlQuery, nil, &data); err != nil {
+ return nil, err
+ }
+
+ // Try nrql first (initial query), then nrqlQueryProgress (poll)
+ if data.Actor.Account.NRQL != nil {
+ return data.Actor.Account.NRQL, nil
+ }
+ if data.Actor.Account.NRQLQueryProgress != nil {
+ return data.Actor.Account.NRQLQueryProgress, nil
+ }
+
+ return nil, fmt.Errorf("invalid GraphQL response: missing nrql or nrqlQueryProgress")
+}
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..87e23ec968
--- /dev/null
+++ b/pkg/integrations/newrelic/example_data_on_issue.json
@@ -0,0 +1,8 @@
+{
+ "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab",
+ "title": "High CPU Usage",
+ "priority": "CRITICAL",
+ "state": "ACTIVATED",
+ "owner": "Team SRE",
+ "issue_url": "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..84fe062f56
--- /dev/null
+++ b/pkg/integrations/newrelic/example_output_report_metric.json
@@ -0,0 +1,9 @@
+{
+ "name": "server.cpu.usage",
+ "type": "gauge",
+ "value": 75.5,
+ "timestamp": 1707552000000,
+ "intervalMs": 0,
+ "status": "202 Accepted",
+ "statusCode": 202
+}
\ 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..a98c68ca6a
--- /dev/null
+++ b/pkg/integrations/newrelic/newrelic.go
@@ -0,0 +1,212 @@
+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 "Newrelic"
+}
+
+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 newrelic 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 {
+ // Create the client first — it decrypts config via GetConfig(),
+ // trims whitespace, and validates that at least one key is present.
+ 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 client.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) Setup(ctx core.WebhookHandlerContext) (any, error) {
+ return map[string]any{"manual": true}, nil
+}
+
+func (h *NewrelicWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
+ return nil
+}
+func (h *NewrelicWebhookHandler) Merge(prev, curr any) (any, bool, error) {
+ return curr, true, nil
+}
diff --git a/pkg/integrations/newrelic/newrelic_test.go b/pkg/integrations/newrelic/newrelic_test.go
new file mode 100644
index 0000000000..32f1e1adcb
--- /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, "Newrelic", 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..8083c2e2dc
--- /dev/null
+++ b/pkg/integrations/newrelic/on_issue.go
@@ -0,0 +1,299 @@
+package newrelic
+
+import (
+ "crypto/subtle"
+ "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 and a Secret. You must configure a **Workflow** in New Relic to send a webhook to this URL.
+
+**IMPORTANT**: You must add a custom header ` + "`X-Superplane-Secret`" + ` in your New Relic Webhook configuration, using the Secret provided by Superplane.
+
+**Payload**:
+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
+ }
+
+ // 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) {
+ // 0. Verify Authentication
+ secret, err := ctx.Webhook.GetSecret()
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("failed to get webhook secret: %w", err)
+ }
+
+ providedSecret := ctx.Headers.Get("X-Superplane-Secret")
+ if providedSecret == "" {
+ return http.StatusUnauthorized, fmt.Errorf("missing X-Superplane-Secret header")
+ }
+
+ if subtle.ConstantTimeCompare([]byte(providedSecret), secret) != 1 {
+ return http.StatusUnauthorized, fmt.Errorf("invalid webhook secret")
+ }
+
+ // 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 Newrelic "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..1be25d5efe
--- /dev/null
+++ b/pkg/integrations/newrelic/on_issue_test.go
@@ -0,0 +1,193 @@
+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("missing auth header -> 401 Unauthorized", func(t *testing.T) {
+ ctx := core.WebhookRequestContext{
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{},
+ Body: []byte(`{}`),
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ assert.Equal(t, http.StatusUnauthorized, status)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "missing X-Superplane-Secret header")
+ })
+
+ t.Run("invalid secret -> 401 Unauthorized", func(t *testing.T) {
+ ctx := core.WebhookRequestContext{
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{
+ "X-Superplane-Secret": {"wrong-secret"},
+ },
+ Body: []byte(`{}`),
+ }
+
+ status, err := trigger.HandleWebhook(ctx)
+ assert.Equal(t, http.StatusUnauthorized, status)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid webhook secret")
+ })
+
+ 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"},
+ },
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{
+ "X-Superplane-Secret": {"test-secret"},
+ },
+ 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"},
+ },
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{
+ "X-Superplane-Secret": {"test-secret"},
+ },
+ 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{},
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{
+ "X-Superplane-Secret": {"test-secret"},
+ },
+ Body: []byte(`{"test": "notification"}`), // Simulate Newrelic 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{},
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Headers: http.Header{
+ "X-Superplane-Secret": {"test-secret"},
+ },
+ 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..d4f2292d22
--- /dev/null
+++ b/pkg/integrations/newrelic/report_metric.go
@@ -0,0 +1,242 @@
+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},
+ },
+ }
+
+ result, err := client.ReportMetric(context.Background(), batch)
+ if 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": result.Status,
+ "statusCode": result.StatusCode,
+ }
+
+ 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..2af9bfe4bc
--- /dev/null
+++ b/pkg/integrations/newrelic/report_metric_test.go
@@ -0,0 +1,483 @@
+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,
+ Status: "200 OK",
+ 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,
+ Status: "200 OK",
+ 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,
+ Status: "200 OK",
+ 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,
+ Status: "200 OK",
+ 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,
+ Status: "202 Accepted",
+ 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)
+
+ // Verify emitted payload contains status
+ require.Len(t, execState.Payloads, 1)
+ payload := execState.Payloads[0].(map[string]any)["data"].(map[string]any)
+ assert.Equal(t, "202 Accepted", payload["status"])
+ assert.Equal(t, http.StatusAccepted, payload["statusCode"])
+ })
+
+ 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..944e6f147d
--- /dev/null
+++ b/pkg/integrations/newrelic/repro_test.go
@@ -0,0 +1,76 @@
+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",
+ },
+ 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..43598f09c5
--- /dev/null
+++ b/pkg/integrations/newrelic/run_nrql_query.go
@@ -0,0 +1,444 @@
+package newrelic
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const RunNRQLQueryPayloadType = "newrelic.nrqlQuery"
+
+// PollInterval is the interval between async query poll attempts.
+const PollInterval = 10 * time.Second
+
+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"`
+}
+
+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"`
+}
+
+// RunNRQLQueryExecutionMetadata stores async query state for polling.
+type RunNRQLQueryExecutionMetadata struct {
+ QueryId string `json:"queryId" mapstructure:"queryId"`
+ AccountID int64 `json:"accountId" mapstructure:"accountId"`
+ Query string `json:"query" mapstructure:"query"`
+}
+
+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)
+
+## How It Works
+
+Queries use New Relic's asynchronous query API:
+
+1. The query is submitted via NerdGraph.
+2. If results are available within the internal API timeout, they are emitted immediately.
+3. If the query takes longer, the component polls for results automatically.
+4. Polling intervals respect the "Retry-After" suggestion from New Relic API, falling back to 10 seconds if not provided.
+
+## 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",
+ },
+ }
+}
+
+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)
+ }
+
+ //
+ // 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)
+ }
+
+ // Execute async NRQL query via NerdGraph (fixed 10s timeout)
+ response, err := client.RunNRQLQuery(context.Background(), accountID, query)
+ if err != nil {
+ return fmt.Errorf("failed to execute NRQL query: %v", err)
+ }
+
+ // If query completed within the timeout, emit results immediately
+ if response.QueryProgress == nil || response.QueryProgress.Completed {
+ return c.emitPayload(ctx.ExecutionState, response, query, accountIDStr)
+ }
+
+ // Query is still running — store state and schedule polling
+ if err := ctx.Metadata.Set(RunNRQLQueryExecutionMetadata{
+ QueryId: response.QueryProgress.QueryId,
+ AccountID: accountID,
+ Query: query,
+ }); err != nil {
+ return fmt.Errorf("failed to store execution metadata: %w", err)
+ }
+
+ interval := PollInterval
+ if response.QueryProgress.RetryAfter > 0 {
+ interval = time.Duration(response.QueryProgress.RetryAfter) * time.Second
+ }
+
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, interval)
+}
+
+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{
+ {
+ Name: "poll",
+ UserAccessible: false,
+ },
+ }
+}
+
+func (c *RunNRQLQuery) HandleAction(ctx core.ActionContext) error {
+ switch ctx.Name {
+ case "poll":
+ return c.poll(ctx)
+ }
+
+ return fmt.Errorf("unknown action: %s", ctx.Name)
+}
+
+func (c *RunNRQLQuery) poll(ctx core.ActionContext) error {
+ if ctx.ExecutionState.IsFinished() {
+ return nil
+ }
+
+ metadata := RunNRQLQueryExecutionMetadata{}
+ if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil {
+ return fmt.Errorf("failed to decode execution metadata: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %v", err)
+ }
+
+ response, err := client.PollNRQLQuery(context.Background(), metadata.AccountID, metadata.QueryId)
+ if err != nil {
+ return fmt.Errorf("failed to poll NRQL query: %v", err)
+ }
+
+ // If still running, check deadline and schedule another poll
+ if response.QueryProgress != nil && !response.QueryProgress.Completed {
+ // Stop polling if we've passed the retry deadline
+ if response.QueryProgress.RetryDeadline > 0 && time.Now().UnixMilli() > response.QueryProgress.RetryDeadline {
+ return fmt.Errorf("async query timed out: retry deadline exceeded")
+ }
+
+ interval := PollInterval
+ if response.QueryProgress.RetryAfter > 0 {
+ interval = time.Duration(response.QueryProgress.RetryAfter) * time.Second
+ }
+
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, interval)
+ }
+
+ // Results are ready — emit them
+ accountIDStr := strconv.FormatInt(metadata.AccountID, 10)
+ return c.emitPayload(ctx.ExecutionState, response, metadata.Query, accountIDStr)
+}
+
+func (c *RunNRQLQuery) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *RunNRQLQuery) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *RunNRQLQuery) emitPayload(state core.ExecutionStateContext, response *NRQLQueryResponse, query, accountIDStr string) error {
+ payload := RunNRQLQueryPayload{
+ Results: response.Results,
+ TotalResult: response.TotalResult,
+ Metadata: response.Metadata,
+ Query: query,
+ AccountID: accountIDStr,
+ }
+
+ return state.Emit(
+ core.DefaultOutputChannel.Name,
+ RunNRQLQueryPayloadType,
+ []any{payload},
+ )
+}
+
+// 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..d9baad113b
--- /dev/null
+++ b/pkg/integrations/newrelic/run_nrql_query_test.go
@@ -0,0 +1,1401 @@
+package newrelic
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "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)
+ assert.Len(t, config, 2) // account and query only, no timeout
+
+ // 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)
+
+ // Verify no timeout field
+ for _, field := range config {
+ assert.NotEqual(t, "timeout", field.Name, "timeout field should not be present in configuration")
+ }
+}
+
+func TestRunNRQLQuery_Actions(t *testing.T) {
+ component := &RunNRQLQuery{}
+ actions := component.Actions()
+
+ require.Len(t, actions, 1)
+ assert.Equal(t, "poll", actions[0].Name)
+ assert.False(t, actions[0].UserAccessible)
+}
+
+func TestClient_RunNRQLQuery_Success(t *testing.T) {
+ t.Run("successful query -> returns results immediately", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [
+ {
+ "count": 1523
+ }
+ ],
+ "metadata": {
+ "eventTypes": ["Transaction"],
+ "messages": [],
+ "timeWindow": {
+ "begin": 1707559740000,
+ "end": 1707563340000
+ }
+ },
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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")
+
+ 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 queryProgress shows completed
+ require.NotNil(t, response.QueryProgress)
+ assert.True(t, response.QueryProgress.Completed)
+
+ // 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 uses async: true
+ 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, "async: true")
+ assert.Contains(t, gqlRequest.Query, "queryProgress")
+ assert.Contains(t, gqlRequest.Query, "SELECT count(*) FROM Transaction SINCE 1 hour ago")
+ })
+
+ t.Run("async query not completed -> returns queryProgress", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "abc-123-def",
+ "completed": false,
+ "retryAfter": 10,
+ "retryDeadline": 1707563340000,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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 Transaction SINCE 24 hours ago")
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.NotNil(t, response.QueryProgress)
+ assert.Equal(t, "abc-123-def", response.QueryProgress.QueryId)
+ assert.False(t, response.QueryProgress.Completed)
+ assert.Equal(t, 10, response.QueryProgress.RetryAfter)
+ })
+
+ 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
+ },
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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")
+
+ 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": [],
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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")
+
+ 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}],
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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")
+
+ 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_PollNRQLQuery(t *testing.T) {
+ t.Run("poll completed -> returns results", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": [{"count": 999}],
+ "totalResult": {"count": 999},
+ "metadata": {
+ "eventTypes": ["Transaction"],
+ "timeWindow": {
+ "begin": 1707559740000,
+ "end": 1707563340000
+ }
+ },
+ "queryProgress": {
+ "queryId": "abc-123-def",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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.PollNRQLQuery(context.Background(), 1234567, "abc-123-def")
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.Len(t, response.Results, 1)
+ assert.Equal(t, float64(999), response.Results[0]["count"])
+
+ // New assertions for metadata and totalResult
+ require.NotNil(t, response.TotalResult)
+ assert.Equal(t, float64(999), response.TotalResult["count"])
+ require.NotNil(t, response.Metadata)
+ assert.Equal(t, []string{"Transaction"}, response.Metadata.EventTypes)
+
+ require.NotNil(t, response.QueryProgress)
+ assert.True(t, response.QueryProgress.Completed)
+
+ // Verify GraphQL query uses nrqlQueryProgress
+ bodyBytes, _ := io.ReadAll(httpCtx.Requests[0].Body)
+ var gqlRequest GraphQLRequest
+ err = json.Unmarshal(bodyBytes, &gqlRequest)
+ require.NoError(t, err)
+ assert.Contains(t, gqlRequest.Query, "nrqlQueryProgress")
+ assert.Contains(t, gqlRequest.Query, "abc-123-def")
+ assert.Contains(t, gqlRequest.Query, "totalResult")
+ assert.Contains(t, gqlRequest.Query, "metadata")
+ })
+
+ t.Run("poll still running -> returns queryProgress not completed", func(t *testing.T) {
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "abc-123-def",
+ "completed": false,
+ "retryAfter": 10,
+ "retryDeadline": 1707563340000,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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.PollNRQLQuery(context.Background(), 1234567, "abc-123-def")
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ require.NotNil(t, response.QueryProgress)
+ assert.False(t, response.QueryProgress.Completed)
+ assert.Equal(t, 10, response.QueryProgress.RetryAfter)
+ })
+}
+
+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")
+
+ 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")
+
+ 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")
+
+ 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")
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "failed to decode GraphQL response")
+ })
+
+ t.Run("missing nrql data 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")
+
+ require.Error(t, err)
+ assert.Nil(t, response)
+ assert.Contains(t, err.Error(), "missing nrql or nrqlQueryProgress")
+ })
+}
+
+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",
+ },
+ 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("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}],
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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, sync completion -> success", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": [{"count": 10}],
+ "queryProgress": {
+ "queryId": "",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 0
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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("async query not completed -> schedules poll", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "async-query-123",
+ "completed": false,
+ "retryAfter": 10,
+ "retryDeadline": 1707563340000,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ 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{}
+ metadataCtx := &contexts.MetadataContext{}
+ requestCtx := &contexts.RequestContext{}
+
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT * FROM Transaction SINCE 24 hours ago",
+ },
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ ExecutionState: executionState,
+ Metadata: metadataCtx,
+ Requests: requestCtx,
+ }
+
+ err := component.Execute(ctx)
+ require.NoError(t, err)
+
+ // Should NOT have emitted results
+ assert.Empty(t, executionState.Payloads)
+ assert.False(t, executionState.Finished)
+
+ // Should have scheduled a poll action
+ assert.Equal(t, "poll", requestCtx.Action)
+ assert.Equal(t, PollInterval, requestCtx.Duration)
+
+ // Should have stored metadata for polling
+ metadata := metadataCtx.Get().(RunNRQLQueryExecutionMetadata)
+ assert.Equal(t, "async-query-123", metadata.QueryId)
+ assert.Equal(t, int64(1234567), metadata.AccountID)
+ assert.Equal(t, "SELECT * FROM Transaction SINCE 24 hours ago", metadata.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'")
+ })
+}
+
+func TestRunNRQLQuery_HandleAction_Poll(t *testing.T) {
+ t.Run("poll completed -> emits results", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ pollResponseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": [{"count": 500}],
+ "queryProgress": {
+ "queryId": "async-query-123",
+ "completed": true,
+ "retryAfter": 0,
+ "retryDeadline": 0,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(pollResponseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{}
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{
+ "queryId": "async-query-123",
+ "accountId": float64(1234567),
+ "query": "SELECT * FROM Transaction SINCE 24 hours ago",
+ },
+ }
+
+ ctx := core.ActionContext{
+ Name: "poll",
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ ExecutionState: executionState,
+ Metadata: metadataCtx,
+ Requests: &contexts.RequestContext{},
+ }
+
+ err := component.HandleAction(ctx)
+ require.NoError(t, err)
+
+ // Should have emitted results
+ 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 * FROM Transaction SINCE 24 hours ago", payload.Query)
+ assert.Equal(t, float64(500), payload.Results[0]["count"])
+ })
+
+ t.Run("poll still running -> reschedules", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ // Use a deadline in the future so it doesn't time out
+ futureDeadline := time.Now().Add(1 * time.Hour).UnixMilli()
+
+ pollResponseJSON := fmt.Sprintf(`{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "async-query-123",
+ "completed": false,
+ "retryAfter": 10,
+ "retryDeadline": %d,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`, futureDeadline)
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(pollResponseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{}
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{
+ "queryId": "async-query-123",
+ "accountId": float64(1234567),
+ "query": "SELECT * FROM Transaction SINCE 24 hours ago",
+ },
+ }
+ requestCtx := &contexts.RequestContext{}
+
+ ctx := core.ActionContext{
+ Name: "poll",
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ ExecutionState: executionState,
+ Metadata: metadataCtx,
+ Requests: requestCtx,
+ }
+
+ err := component.HandleAction(ctx)
+ require.NoError(t, err)
+
+ // Should NOT have emitted results
+ assert.Empty(t, executionState.Payloads)
+ assert.False(t, executionState.Finished)
+
+ // Should have re-scheduled a poll
+ assert.Equal(t, "poll", requestCtx.Action)
+ assert.Equal(t, PollInterval, requestCtx.Duration)
+ })
+
+ t.Run("poll deadline exceeded -> returns error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ // Simulate a deadline in the past
+ pastDeadline := time.Now().Add(-1 * time.Minute).UnixMilli()
+
+ pollResponseJSON := fmt.Sprintf(`{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": null,
+ "totalResult": null,
+ "metadata": null,
+ "queryProgress": {
+ "queryId": "async-query-123",
+ "completed": false,
+ "retryAfter": 10,
+ "retryDeadline": %d,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`, pastDeadline)
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(pollResponseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{}
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{
+ "queryId": "async-query-123",
+ "accountId": float64(1234567),
+ "query": "SELECT * FROM Transaction SINCE 24 hours ago",
+ },
+ }
+
+ ctx := core.ActionContext{
+ Name: "poll",
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "test-key",
+ "site": "US",
+ },
+ },
+ ExecutionState: executionState,
+ Metadata: metadataCtx,
+ Requests: &contexts.RequestContext{},
+ }
+
+ err := component.HandleAction(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "async query timed out: retry deadline exceeded")
+ })
+
+ t.Run("already finished -> noop", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ executionState := &contexts.ExecutionStateContext{
+ Finished: true,
+ }
+
+ ctx := core.ActionContext{
+ Name: "poll",
+ ExecutionState: executionState,
+ }
+
+ err := component.HandleAction(ctx)
+ require.NoError(t, err)
+ })
+
+ t.Run("unknown action -> error", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ ctx := core.ActionContext{
+ Name: "unknown",
+ }
+
+ err := component.HandleAction(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown action: unknown")
+ })
+}
+func TestRunNRQLQuery_Execute_RetryAfter(t *testing.T) {
+ t.Run("async query with custom retryAfter -> schedules poll with custom interval", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ responseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrql": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "async-query-789",
+ "completed": false,
+ "retryAfter": 30,
+ "retryDeadline": 0,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(responseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ requestCtx := &contexts.RequestContext{}
+ ctx := core.ExecutionContext{
+ Configuration: map[string]any{
+ "account": "1234567",
+ "query": "SELECT count(*) FROM Transaction",
+ },
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "key",
+ "site": "US",
+ },
+ },
+ ExecutionState: &contexts.ExecutionStateContext{},
+ Metadata: &contexts.MetadataContext{},
+ Requests: requestCtx,
+ }
+
+ err := component.Execute(ctx)
+ require.NoError(t, err)
+
+ // Verify custom interval (30s)
+ assert.Equal(t, 30*time.Second, requestCtx.Duration)
+ })
+}
+
+func TestRunNRQLQuery_Poll_RetryAfter(t *testing.T) {
+ t.Run("poll not completed with custom retryAfter -> schedules next poll with custom interval", func(t *testing.T) {
+ component := &RunNRQLQuery{}
+
+ pollResponseJSON := `{
+ "data": {
+ "actor": {
+ "account": {
+ "nrqlQueryProgress": {
+ "results": null,
+ "queryProgress": {
+ "queryId": "async-query-123",
+ "completed": false,
+ "retryAfter": 45,
+ "retryDeadline": 0,
+ "resultExpiration": 1707563940000
+ }
+ }
+ }
+ }
+ }
+ }`
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(pollResponseJSON)),
+ Header: make(http.Header),
+ },
+ },
+ }
+
+ requestCtx := &contexts.RequestContext{}
+ ctx := core.ActionContext{
+ Name: "poll",
+ HTTP: httpCtx,
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "userApiKey": "key",
+ "site": "US",
+ },
+ },
+ ExecutionState: &contexts.ExecutionStateContext{},
+ Metadata: &contexts.MetadataContext{
+ Metadata: map[string]any{
+ "queryId": "async-query-123",
+ "accountId": float64(1234567),
+ "query": "SELECT * FROM Transaction",
+ },
+ },
+ Requests: requestCtx,
+ }
+
+ err := component.HandleAction(ctx)
+ require.NoError(t, err)
+
+ // Verify custom interval (45s)
+ assert.Equal(t, 45*time.Second, requestCtx.Duration)
+ })
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 599a92db8f..c760be22d2 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -49,6 +49,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/grafana"
_ "github.com/superplanehq/superplane/pkg/integrations/hetzner"
_ "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/prometheus"