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"