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