From 9a0df7d6535d448b007be1a210d293d245792d6e Mon Sep 17 00:00:00 2001 From: Teshome Birhanu Date: Thu, 12 Feb 2026 22:40:49 +0300 Subject: [PATCH 1/3] feat: add New Relic integration and fix code review issues Signed-off-by: Teshome Birhanu --- docs/components/New Relic.mdx | 165 +++++ pkg/integrations/newrelic/client.go | 395 +++++++++++ pkg/integrations/newrelic/common.go | 46 ++ pkg/integrations/newrelic/example.go | 38 + .../newrelic/example_data_on_issue.json | 12 + .../example_output_report_metric.json | 8 + .../example_output_run_nrql_query.json | 21 + pkg/integrations/newrelic/newrelic.go | 207 ++++++ pkg/integrations/newrelic/newrelic_test.go | 426 +++++++++++ pkg/integrations/newrelic/on_issue.go | 300 ++++++++ pkg/integrations/newrelic/on_issue_test.go | 139 ++++ pkg/integrations/newrelic/report_metric.go | 243 +++++++ .../newrelic/report_metric_test.go | 447 ++++++++++++ pkg/integrations/newrelic/repro_test.go | 55 ++ pkg/integrations/newrelic/run_nrql_query.go | 345 +++++++++ .../newrelic/run_nrql_query_test.go | 665 ++++++++++++++++++ pkg/server/server.go | 1 + 17 files changed, 3513 insertions(+) create mode 100644 docs/components/New Relic.mdx create mode 100644 pkg/integrations/newrelic/client.go create mode 100644 pkg/integrations/newrelic/common.go create mode 100644 pkg/integrations/newrelic/example.go create mode 100644 pkg/integrations/newrelic/example_data_on_issue.json create mode 100644 pkg/integrations/newrelic/example_output_report_metric.json create mode 100644 pkg/integrations/newrelic/example_output_run_nrql_query.json create mode 100644 pkg/integrations/newrelic/newrelic.go create mode 100644 pkg/integrations/newrelic/newrelic_test.go create mode 100644 pkg/integrations/newrelic/on_issue.go create mode 100644 pkg/integrations/newrelic/on_issue_test.go create mode 100644 pkg/integrations/newrelic/report_metric.go create mode 100644 pkg/integrations/newrelic/report_metric_test.go create mode 100644 pkg/integrations/newrelic/repro_test.go create mode 100644 pkg/integrations/newrelic/run_nrql_query.go create mode 100644 pkg/integrations/newrelic/run_nrql_query_test.go diff --git a/docs/components/New Relic.mdx b/docs/components/New Relic.mdx new file mode 100644 index 0000000000..6dc68b8a17 --- /dev/null +++ b/docs/components/New Relic.mdx @@ -0,0 +1,165 @@ +--- +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. **Get API Key**: In New Relic, go to Account Settings > API Keys +3. **Create API Key**: Click "Create API key" and give it a name +4. **Copy API Key**: Copy the generated API key (you won't be able to see it again) +5. **Paste API Key**: Paste the API key in the field below + +## Usage + +- **Report Metric**: Use this action to send custom telemetry (Gauge, Count, Summary) to New Relic. +- **Run NRQL Query**: Use this action to fetch data from New Relic for decision making or reporting. + +## Note + +The **Report Metric** action requires a **License Key** (Ingest - License) or an **API Key** (User) with ingest permissions. +If you use a User API Key, it must have the necessary permissions. A License Key is generally recommended for metric ingestion. +The **Run NRQL Query** action requires a **User API Key** (NRAK-) with query permissions. + + + +## 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 +{ + "impacted_entities": [ + "infra-host-1" + ], + "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab", + "issue_url": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=", + "owner": "Team SRE", + "priority": "CRITICAL", + "state": "ACTIVATED", + "title": "High CPU Usage", + "total_incidents": 1 +} +``` + + + +## 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. +- **Attributes**: Optional JSON object with additional attributes + +### Output + +Returns the sent metric payload. + +### Example Output + +```json +{ + "attributes": { + "environment": "production", + "host": "server1.example.com" + }, + "metricName": "server.cpu.usage", + "metricType": "gauge", + "timestamp": 1707552000000, + "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 ID**: The New Relic account ID to query against (required) +- **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 + diff --git a/pkg/integrations/newrelic/client.go b/pkg/integrations/newrelic/client.go new file mode 100644 index 0000000000..1ee800ef00 --- /dev/null +++ b/pkg/integrations/newrelic/client.go @@ -0,0 +1,395 @@ +package newrelic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +type Client struct { + APIKey string + BaseURL string + NerdGraphURL string + MetricBaseURL string + http core.HTTPContext +} + +func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + apiKey, err := ctx.GetConfig("apiKey") + if err != nil { + return nil, fmt.Errorf("API key is required: %w", err) + } + + key := strings.TrimSpace(string(apiKey)) + if key == "" { + return nil, fmt.Errorf("API key is required") + } + + site, err := ctx.GetConfig("site") + if err != nil { + return nil, fmt.Errorf("failed to get site: %w", err) + } + + var baseURL, nerdGraphURL, metricBaseURL string + if string(site) == "EU" { + baseURL = restAPIBaseEU + nerdGraphURL = nerdGraphAPIBaseEU + metricBaseURL = metricsAPIBaseEU + } else { + baseURL = restAPIBaseUS + nerdGraphURL = nerdGraphAPIBaseUS + metricBaseURL = metricsAPIBaseUS + } + + return &Client{ + APIKey: key, + BaseURL: baseURL, + 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 isUserAPIKey(apiKey string) bool { + return strings.HasPrefix(apiKey, "NRAK-") +} + +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) + } + + if isUserAPIKey(c.APIKey) { + req.Header.Set("Api-Key", c.APIKey) + } else { + req.Header.Set("X-License-Key", c.APIKey) + } + 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 +} +func (c *Client) ValidateAPIKey(ctx context.Context) error { + graphqlQuery := `{ actor { user { name email } } }` + + gqlRequest := GraphQLRequest{ + Query: graphqlQuery, + } + + 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.APIKey) + 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, "; ")) + } + + if gqlResponse.Data == nil { + return fmt.Errorf("no data returned from identity query") + } + + return nil +} + +// ListAccounts fetches the list of accounts the API key has access to +func (c *Client) ListAccounts(ctx context.Context) ([]Account, error) { + graphqlQuery := `{ actor { accounts { id name } } }` + + 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.APIKey) + 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 || actor == nil { + return nil, fmt.Errorf("invalid GraphQL response: missing actor") + } + + accountsData, ok := actor["accounts"].([]interface{}) + if !ok { + return []Account{}, nil + } + + accounts := make([]Account, 0, len(accountsData)) + for _, accData := range accountsData { + m, ok := accData.(map[string]interface{}) + if !ok { + continue + } + + var id int64 + switch v := m["id"].(type) { + case float64: + id = int64(v) + case int64: + id = v + case string: + id, _ = strconv.ParseInt(v, 10, 64) + } + + name, _ := m["name"].(string) + + // FIXED: Added filter to skip invalid or empty accounts + if id == 0 || name == "" { + continue + } + + accounts = append(accounts, Account{ + ID: id, + Name: name, + }) + } + + return 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.APIKey) + 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 +} \ No newline at end of file diff --git a/pkg/integrations/newrelic/common.go b/pkg/integrations/newrelic/common.go new file mode 100644 index 0000000000..e4a6f6f840 --- /dev/null +++ b/pkg/integrations/newrelic/common.go @@ -0,0 +1,46 @@ +package newrelic + +import ( + "encoding/json" + "fmt" +) + +const ( + // US Region + restAPIBaseUS = "https://api.newrelic.com/v2" + nerdGraphAPIBaseUS = "https://api.newrelic.com/graphql" + metricsAPIBaseUS = "https://metric-api.newrelic.com/metric/v1" + + // EU Region + restAPIBaseEU = "https://api.eu.newrelic.com/v2" + 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..8fa6daa00a --- /dev/null +++ b/pkg/integrations/newrelic/example_data_on_issue.json @@ -0,0 +1,12 @@ +{ + "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab", + "title": "High CPU Usage", + "priority": "CRITICAL", + "state": "ACTIVATED", + "owner": "Team SRE", + "issue_url": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=", + "impacted_entities": [ + "infra-host-1" + ], + "total_incidents": 1 +} \ 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..c90e9e6605 --- /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" +} 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..60deed737d --- /dev/null +++ b/pkg/integrations/newrelic/newrelic.go @@ -0,0 +1,207 @@ +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. **Get API Key**: In New Relic, go to Account Settings > API Keys +3. **Create API Key**: Click "Create API key" and give it a name +4. **Copy API Key**: Copy the generated API key (you won't be able to see it again) +5. **Paste API Key**: Paste the API key in the field below + +## Usage + +- **Report Metric**: Use this action to send custom telemetry (Gauge, Count, Summary) to New Relic. +- **Run NRQL Query**: Use this action to fetch data from New Relic for decision making or reporting. + +## Note + +The **Report Metric** action requires a **License Key** (Ingest - License) or an **API Key** (User) with ingest permissions. +If you use a User API Key, it must have the necessary permissions. A License Key is generally recommended for metric ingestion. +The **Run NRQL Query** action requires a **User API Key** (NRAK-) with query permissions. +` + +func init() { + registry.RegisterIntegrationWithWebhookHandler("newrelic", &NewRelic{}, &NewRelicWebhookHandler{}) +} + +type NewRelic struct{} + +type Configuration struct { + APIKey string `json:"apiKey" mapstructure:"apiKey"` + 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: "apiKey", + Label: "API Key", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "New Relic API key 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) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // 1. Validate the API Key + err = client.ValidateAPIKey(context.Background()) + if err != nil { + return fmt.Errorf("failed to validate API key: %w", err) + } + + // 2. Fetch accounts so the UI can populate the \"Account\" dropdowns + 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{} + } + + // 3. Save to Metadata + ctx.Integration.SetMetadata(Metadata{ + Accounts: accounts, + }) + + ctx.Integration.Ready() + return nil +} + + +func (n *NewRelic) HandleRequest(ctx core.HTTPRequestContext) { + // Webhooks will be handled by triggers +} + +func (n *NewRelic) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (n *NewRelic) Actions() []core.Action { + return []core.Action{} +} + +func (n *NewRelic) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (n *NewRelic) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if resourceType != "account" { + return []core.IntegrationResource{}, nil + } + + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(metadata.Accounts)) + for _, account := range metadata.Accounts { + resources = append(resources, core.IntegrationResource{ + Type: "account", + Name: account.Name, + ID: fmt.Sprintf("%d", account.ID), + }) + } + + return resources, nil +} + + +type NewRelicWebhookHandler struct{} + +func (h *NewRelicWebhookHandler) CompareConfig(a, b any) (bool, error) { + if a == nil && b == nil { + return true, nil + } + return reflect.DeepEqual(a, b), nil +} + +func (h *NewRelicWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + return map[string]any{"manual": true}, nil +} + +func (h *NewRelicWebhookHandler) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + trigger := &OnIssue{} + return trigger.HandleWebhook(ctx) +} + +func (h *NewRelicWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + return nil +} \ No newline at end of file diff --git a/pkg/integrations/newrelic/newrelic_test.go b/pkg/integrations/newrelic/newrelic_test.go new file mode 100644 index 0000000000..f7d8883adb --- /dev/null +++ b/pkg/integrations/newrelic/newrelic_test.go @@ -0,0 +1,426 @@ +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("missing API key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": "US", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: map[string]any{}, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "API key is required") + }) + + t.Run("empty API key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: map[string]any{"apiKey": "", "site": "US"}, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "API key is required") + }) + + t.Run("invalid 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{ + "apiKey": "invalid-key", + "site": "US", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: map[string]any{"apiKey": "invalid-key", "site": "US"}, + Integration: integrationCtx, + HTTP: httpCtx, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to validate 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, "invalid-key", httpCtx.Requests[0].Header.Get("Api-Key")) + }) + + t.Run("valid API key -> 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"} + ] + } + } + }`), + }, + } + + apiKey := "test-api-key" + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": apiKey, + "site": "US", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: map[string]any{"apiKey": apiKey, "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, "test-api-key", httpCtx.Requests[0].Header.Get("Api-Key")) + }) + + t.Run("network error -> error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{}, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-key", + "site": "US", + }, + } + + err := n.Sync(core.SyncContext{ + Configuration: map[string]any{"apiKey": "test-key", "site": "US"}, + Integration: integrationCtx, + HTTP: httpCtx, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to validate API key") + }) +} + +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("missing API key -> 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(), "API key is required") + }) + + t.Run("empty API key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "", + }, + } + + _, err := NewClient(&contexts.HTTPContext{}, integrationCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "API key is required") + }) + + t.Run("valid API key -> success", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-key", + "site": "US", + }, + } + + client, err := NewClient(&contexts.HTTPContext{}, integrationCtx) + + require.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test-key", client.APIKey) + assert.Equal(t, "https://api.newrelic.com/v2", client.BaseURL) + assert.Equal(t, "https://api.newrelic.com/graphql", client.NerdGraphURL) + }) + + t.Run("valid EU API key -> success", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-key", + "site": "EU", + }, + } + + client, err := NewClient(&contexts.HTTPContext{}, integrationCtx) + + require.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test-key", client.APIKey) + assert.Equal(t, "https://api.eu.newrelic.com/v2", client.BaseURL) + assert.Equal(t, "https://api.eu.newrelic.com/graphql", client.NerdGraphURL) + }) +} + +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{ + APIKey: "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, "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{ + APIKey: "license-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{ + APIKey: 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{ + APIKey: "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{ + APIKey: "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.Equal(t, "site", config[0].Name) + assert.True(t, config[0].Required) + assert.Equal(t, "apiKey", config[1].Name) + assert.True(t, config[1].Required) + assert.True(t, config[1].Sensitive) +} diff --git a/pkg/integrations/newrelic/on_issue.go b/pkg/integrations/newrelic/on_issue.go new file mode 100644 index 0000000000..3b2deed52f --- /dev/null +++ b/pkg/integrations/newrelic/on_issue.go @@ -0,0 +1,300 @@ +package newrelic + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/mitchellh/mapstructure" + "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"` + Account string `json:"account" yaml:"account" mapstructure:"account"` + ManualAccountID string `json:"manualAccountId" yaml:"manualAccountId" mapstructure:"manualAccountId"` +} + +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: "account", + Label: "Account", + Type: configuration.FieldTypeIntegrationResource, + Required: false, // Optional to prevent blocking + Description: "The New Relic account (optional for webhook)", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "account", + }, + }, + }, + { + Name: "manualAccountId", + Label: "Manual Account ID", + Type: configuration.FieldTypeString, + Required: false, + Description: "Manually enter Account ID if dropdown fails", + Placeholder: "1234567", + }, + { + 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 { + // 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 { + fmt.Printf("Error decoding configuration: %v\n", err) + // Azure Pattern: Return 200 OK on malformed config to avoid retries/disable + return http.StatusOK, nil + } + + // 2. Parse Payload into Map (Handshake Check) + var rawPayload map[string]any + if len(ctx.Body) == 0 { + fmt.Println("New Relic Validation Ping Received (Empty Body)") + return http.StatusOK, nil + } + if err := json.Unmarshal(ctx.Body, &rawPayload); err != nil { + fmt.Printf("Error parsing webhook body: %v\n", err) + return http.StatusOK, nil + } + + // 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 { + fmt.Println("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 { + fmt.Printf("Error creating decoder: %v\n", err) + return http.StatusOK, nil + } + + if err := decoder.Decode(rawPayload); err != nil { + fmt.Printf("Error decoding payload: %v\n", err) + return http.StatusOK, nil + } + + // 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" + 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 +} \ No newline at end of file diff --git a/pkg/integrations/newrelic/on_issue_test.go b/pkg/integrations/newrelic/on_issue_test.go new file mode 100644 index 0000000000..6c9151ae4f --- /dev/null +++ b/pkg/integrations/newrelic/on_issue_test.go @@ -0,0 +1,139 @@ +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{}, + 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{}, + 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..1bb467b1b1 --- /dev/null +++ b/pkg/integrations/newrelic/report_metric.go @@ -0,0 +1,243 @@ +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"` + MetricType string `json:"metricType" yaml:"metricType"` + Value float64 `json:"value" yaml:"value"` + Timestamp int64 `json:"timestamp" yaml:"timestamp"` + IntervalMs int64 `json:"intervalMs" yaml:"intervalMs"` + Attributes map[string]any `json:"attributes" yaml:"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 { + 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 +} + +func (c *ReportMetric) SampleOutput() any { + return map[string]any{ + "name": "server.cpu.usage", + "value": 95.5, + "type": "gauge", + "timestamp": 1707584119000, + "status": "202 Accepted", + } +} \ No newline at end of file diff --git a/pkg/integrations/newrelic/report_metric_test.go b/pkg/integrations/newrelic/report_metric_test.go new file mode 100644 index 0000000000..50908fb3d6 --- /dev/null +++ b/pkg/integrations/newrelic/report_metric_test.go @@ -0,0 +1,447 @@ +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{} + + 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, + }, + }) + + 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 + }, + }) + + 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 + }, + }) + + 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, + }, + }) + + require.NoError(t, err) + }) +} + +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{ + APIKey: "test-key", + BaseURL: "https://api.newrelic.com/v2", + 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("User API Key (NRAK) request -> 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{ + APIKey: "NRAK-test-key", + BaseURL: "https://api.newrelic.com/v2", + 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{ + APIKey: "test-key", + BaseURL: "https://api.newrelic.com/v2", + 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{ + APIKey: "test-key", + BaseURL: "https://api.newrelic.com/v2", + 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{ + APIKey: "eu-test-key", + BaseURL: "https://api.eu.newrelic.com/v2", + 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{ + "apiKey": "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{ + "apiKey": "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{ + "apiKey": "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{ + "apiKey": "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..07bc9cf5d4 --- /dev/null +++ b/pkg/integrations/newrelic/repro_test.go @@ -0,0 +1,55 @@ +package newrelic + +import ( + "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, + }, + { + name: "manual account id fallback", + configuration: map[string]any{ + // account field is missing/nil, simulating UI issue + "manualAccountId": "12345", + "query": "SELECT count(*) FROM Transaction", + "timeout": 10, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := core.SetupContext{ + 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..aaee71d02d --- /dev/null +++ b/pkg/integrations/newrelic/run_nrql_query.go @@ -0,0 +1,345 @@ +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"` + ManualAccountID string `json:"manualAccountId" mapstructure:"manualAccountId"` + 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"` +} + +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 ID**: The New Relic account ID to query against (required) +- **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: false, // Changed to false to allow manual override + Description: "The New Relic account to query", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "account", + }, + }, + }, + { + Name: "manualAccountId", + Label: "Manual Account ID", + Type: configuration.FieldTypeString, + Required: false, + Description: "Manually enter Account ID if dropdown fails", + Placeholder: "1234567", + }, + { + 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 { + spec := RunNRQLQuerySpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + // Set manual: true in metadata to ensure UI handles generic components correctly + if err := ctx.Metadata.Set(map[string]any{"manual": true}); err != nil { + return fmt.Errorf("failed to set metadata: %w", err) + } + + accountID := spec.Account + if spec.ManualAccountID != "" { + accountID = spec.ManualAccountID + } + + if accountID == "" { + return fmt.Errorf("account is required (select from dropdown or use Manual Account ID)") + } + + // Guard: reject unresolved template tags early + if isUnresolvedTemplate(accountID) { + return fmt.Errorf("account ID contains unresolved template variable: %s — configure the upstream trigger first", accountID) + } + + 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") + } + + 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 + if spec.ManualAccountID != "" { + accountIDStr = spec.ManualAccountID + } + + // 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, 64) + } + + // 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) +} \ No newline at end of file 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..6f03e76333 --- /dev/null +++ b/pkg/integrations/newrelic/run_nrql_query_test.go @@ -0,0 +1,665 @@ +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.False(t, *accountIDField) + 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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{ + APIKey: "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{} + ctx := core.SetupContext{ + Configuration: map[string]any{ + "account": "1234567", + "query": "SELECT count(*) FROM Transaction", + "timeout": 10, + }, + Metadata: &contexts.MetadataContext{}, + } + + err := component.Setup(ctx) + assert.NoError(t, err) + }) + + t.Run("missing account -> returns error", func(t *testing.T) { + component := &RunNRQLQuery{} + ctx := core.SetupContext{ + Configuration: map[string]any{ + "query": "SELECT count(*) FROM Transaction", + }, + 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", + }, + 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 + }, + Metadata: &contexts.MetadataContext{}, + } + + err := component.Setup(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout must be between 0 and 120") + }) +} + +func TestRunNRQLQuery_Setup_TemplateGuard(t *testing.T) { + component := &RunNRQLQuery{} + + 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", + }, + 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}}", + }, + 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{ + "apiKey": "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{ + "apiKey": "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{ + "apiKey": "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{ + "apiKey": "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{ + "apiKey": "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" From ebda765935bddaddf71826a2ad466f06e48849f0 Mon Sep 17 00:00:00 2001 From: Teshome Birhanu Date: Fri, 13 Feb 2026 09:39:21 +0300 Subject: [PATCH 2/3] feat: add New Relic integration and fix component issues Signed-off-by: Teshome Birhanu --- .../newrelic/example_data_on_issue.json | 8 ++----- pkg/integrations/newrelic/newrelic.go | 12 ++++++---- pkg/integrations/newrelic/on_issue.go | 24 ++++++++++--------- pkg/integrations/newrelic/report_metric.go | 12 ++-------- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/pkg/integrations/newrelic/example_data_on_issue.json b/pkg/integrations/newrelic/example_data_on_issue.json index 8fa6daa00a..456e3c3ed2 100644 --- a/pkg/integrations/newrelic/example_data_on_issue.json +++ b/pkg/integrations/newrelic/example_data_on_issue.json @@ -1,12 +1,8 @@ { - "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab", + "issueId": "12345678-abcd-efgh-ijkl-1234567890ab", "title": "High CPU Usage", "priority": "CRITICAL", "state": "ACTIVATED", "owner": "Team SRE", - "issue_url": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=", - "impacted_entities": [ - "infra-host-1" - ], - "total_incidents": 1 + "issueUrl": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=" } \ No newline at end of file diff --git a/pkg/integrations/newrelic/newrelic.go b/pkg/integrations/newrelic/newrelic.go index 60deed737d..ea6a92d7e2 100644 --- a/pkg/integrations/newrelic/newrelic.go +++ b/pkg/integrations/newrelic/newrelic.go @@ -110,10 +110,14 @@ func (n *NewRelic) Triggers() []core.Trigger { } 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) - } + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.APIKey == "" { + return fmt.Errorf("API key is required") + } client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { diff --git a/pkg/integrations/newrelic/on_issue.go b/pkg/integrations/newrelic/on_issue.go index 3b2deed52f..e89b534785 100644 --- a/pkg/integrations/newrelic/on_issue.go +++ b/pkg/integrations/newrelic/on_issue.go @@ -7,6 +7,7 @@ import ( "slices" "github.com/mitchellh/mapstructure" + log "github.com/sirupsen/logrus" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" ) @@ -204,20 +205,19 @@ func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { // 1. Decode Configuration config := OnIssueConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { - fmt.Printf("Error decoding configuration: %v\n", err) - // Azure Pattern: Return 200 OK on malformed config to avoid retries/disable - return http.StatusOK, 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 { - fmt.Println("New Relic Validation Ping Received (Empty Body)") + log.Infof("New Relic Validation Ping Received (Empty Body)") return http.StatusOK, nil } if err := json.Unmarshal(ctx.Body, &rawPayload); err != nil { - fmt.Printf("Error parsing webhook body: %v\n", err) - return http.StatusOK, 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) @@ -225,7 +225,7 @@ func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { _, hasIssueID := rawPayload["issue_id"] if !hasIssueID { - fmt.Println("New Relic Validation Ping Received") + log.Infof("New Relic Validation Ping Received") return http.StatusOK, nil } @@ -237,13 +237,13 @@ func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { TagName: "json", }) if err != nil { - fmt.Printf("Error creating decoder: %v\n", err) - return http.StatusOK, 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 { - fmt.Printf("Error decoding payload: %v\n", err) - return http.StatusOK, nil + log.Errorf("Error decoding payload: %v", err) + return http.StatusBadRequest, fmt.Errorf("error decoding payload: %w", err) } // 5. Filter Logic @@ -260,6 +260,8 @@ func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { eventName = "newrelic.issue_activated" case "CLOSED": eventName = "newrelic.issue_closed" + case "CREATED": + eventName = "newrelic.issue_created" default: eventName = "newrelic.issue_updated" } diff --git a/pkg/integrations/newrelic/report_metric.go b/pkg/integrations/newrelic/report_metric.go index 1bb467b1b1..ef72457206 100644 --- a/pkg/integrations/newrelic/report_metric.go +++ b/pkg/integrations/newrelic/report_metric.go @@ -66,6 +66,8 @@ func (c *ReportMetric) OutputChannels(configuration any) []core.OutputChannel { return []core.OutputChannel{core.DefaultOutputChannel} } + + func (c *ReportMetric) Configuration() []configuration.Field { return []configuration.Field{ { @@ -231,13 +233,3 @@ func (c *ReportMetric) HandleWebhook(ctx core.WebhookRequestContext) (int, error func (c *ReportMetric) Cleanup(ctx core.SetupContext) error { return nil } - -func (c *ReportMetric) SampleOutput() any { - return map[string]any{ - "name": "server.cpu.usage", - "value": 95.5, - "type": "gauge", - "timestamp": 1707584119000, - "status": "202 Accepted", - } -} \ No newline at end of file From ba658432adb20c8275bbd91c6e7e73451c48cf32 Mon Sep 17 00:00:00 2001 From: Teshome Birhanu Date: Fri, 13 Feb 2026 11:42:20 +0300 Subject: [PATCH 3/3] feat: fix New Relic integration and resolve build infra cache issues Signed-off-by: Teshome Birhanu --- docker-compose.dev.yml | 4 +- docs/components/New Relic.mdx | 66 +++--- pkg/integrations/newrelic/client.go | 161 ++++--------- pkg/integrations/newrelic/common.go | 2 - .../example_output_report_metric.json | 2 +- pkg/integrations/newrelic/newrelic.go | 108 +++++---- pkg/integrations/newrelic/newrelic_test.go | 211 ++++++++++++++---- pkg/integrations/newrelic/on_issue.go | 36 +-- pkg/integrations/newrelic/on_issue_test.go | 23 +- pkg/integrations/newrelic/report_metric.go | 49 ++-- .../newrelic/report_metric_test.go | 65 ++++-- pkg/integrations/newrelic/repro_test.go | 42 +++- pkg/integrations/newrelic/run_nrql_query.go | 92 +++++--- .../newrelic/run_nrql_query_test.go | 160 ++++++++++--- 14 files changed, 637 insertions(+), 384 deletions(-) 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 index 6dc68b8a17..666f8aed13 100644 --- a/docs/components/New Relic.mdx +++ b/docs/components/New Relic.mdx @@ -24,21 +24,16 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; To set up New Relic integration: 1. **Select Region**: Choose your New Relic region (US or EU) -2. **Get API Key**: In New Relic, go to Account Settings > API Keys -3. **Create API Key**: Click "Create API key" and give it a name -4. **Copy API Key**: Copy the generated API key (you won't be able to see it again) -5. **Paste API Key**: Paste the API key in the field below +2. **Provide API Keys**: You can provide one or both keys depending on which components you need. -## Usage +## API Keys -- **Report Metric**: Use this action to send custom telemetry (Gauge, Count, Summary) to New Relic. -- **Run NRQL Query**: Use this action to fetch data from New Relic for decision making or reporting. +New Relic uses two different types of API keys for different purposes: -## Note +- **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. -The **Report Metric** action requires a **License Key** (Ingest - License) or an **API Key** (User) with ingest permissions. -If you use a User API Key, it must have the necessary permissions. A License Key is generally recommended for metric ingestion. -The **Run NRQL Query** action requires a **User API Key** (NRAK-) with query permissions. +You may provide both keys to enable all components, or just the key(s) for the components you need. @@ -77,16 +72,12 @@ This trigger generates a webhook URL. You must configure a **Workflow** in New R ```json { - "impacted_entities": [ - "infra-host-1" - ], - "issue_id": "12345678-abcd-efgh-ijkl-1234567890ab", - "issue_url": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJuZXJkbGV0SWQiOiJhbGVydGluZy11aS1jbGFzc2ljLmluY2lkZW50cyIsInNlbGVjdGVkSW5jaWRlbnRJZCI6IjEyMzQ1Njc4In0=", + "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", - "total_incidents": 1 + "title": "High CPU Usage" } ``` @@ -102,6 +93,7 @@ The Report Metric component allows you to send custom metrics (Gauge, Count, Sum - **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 @@ -112,13 +104,11 @@ Returns the sent metric payload. ```json { - "attributes": { - "environment": "production", - "host": "server1.example.com" - }, - "metricName": "server.cpu.usage", - "metricType": "gauge", + "intervalMs": 0, + "name": "server.cpu.usage", + "status": "202 Accepted", "timestamp": 1707552000000, + "type": "gauge", "value": 75.5 } ``` @@ -138,7 +128,7 @@ The Run NRQL Query component allows you to execute NRQL queries via New Relic's ### Configuration -- **Account ID**: The New Relic account ID to query against (required) +- **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) @@ -163,3 +153,29 @@ Returns query results including: - 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 index 1ee800ef00..998104effd 100644 --- a/pkg/integrations/newrelic/client.go +++ b/pkg/integrations/newrelic/client.go @@ -14,22 +14,26 @@ import ( ) type Client struct { - APIKey string - BaseURL string + UserAPIKey string + LicenseKey string NerdGraphURL string MetricBaseURL string http core.HTTPContext } func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { - apiKey, err := ctx.GetConfig("apiKey") - if err != nil { - return nil, fmt.Errorf("API key is required: %w", err) + 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)) } - key := strings.TrimSpace(string(apiKey)) - if key == "" { - return nil, fmt.Errorf("API key is required") + 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") @@ -37,20 +41,18 @@ func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, return nil, fmt.Errorf("failed to get site: %w", err) } - var baseURL, nerdGraphURL, metricBaseURL string + var nerdGraphURL, metricBaseURL string if string(site) == "EU" { - baseURL = restAPIBaseEU nerdGraphURL = nerdGraphAPIBaseEU metricBaseURL = metricsAPIBaseEU } else { - baseURL = restAPIBaseUS nerdGraphURL = nerdGraphAPIBaseUS metricBaseURL = metricsAPIBaseUS } return &Client{ - APIKey: key, - BaseURL: baseURL, + UserAPIKey: userAPIKey, + LicenseKey: licenseKey, NerdGraphURL: nerdGraphURL, MetricBaseURL: metricBaseURL, http: httpCtx, @@ -79,10 +81,6 @@ type MetricBatch struct { Metrics []Metric `json:"metrics"` } -func isUserAPIKey(apiKey string) bool { - return strings.HasPrefix(apiKey, "NRAK-") -} - func (c *Client) ReportMetric(ctx context.Context, batch []MetricBatch) error { url := c.MetricBaseURL @@ -96,10 +94,11 @@ func (c *Client) ReportMetric(ctx context.Context, batch []MetricBatch) error { return fmt.Errorf("failed to create metric request: %w", err) } - if isUserAPIKey(c.APIKey) { - req.Header.Set("Api-Key", c.APIKey) + // 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("X-License-Key", c.APIKey) + req.Header.Set("Api-Key", c.UserAPIKey) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") @@ -117,11 +116,12 @@ func (c *Client) ReportMetric(ctx context.Context, batch []MetricBatch) error { return nil } -func (c *Client) ValidateAPIKey(ctx context.Context) error { - graphqlQuery := `{ actor { user { name email } } }` +// 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: graphqlQuery, + Query: query, + Variables: variables, } bodyBytes, err := json.Marshal(gqlRequest) @@ -134,7 +134,7 @@ func (c *Client) ValidateAPIKey(ctx context.Context) error { return fmt.Errorf("failed to create NerdGraph request: %w", err) } - req.Header.Set("Api-Key", c.APIKey) + req.Header.Set("Api-Key", c.UserAPIKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") @@ -166,104 +166,35 @@ func (c *Client) ValidateAPIKey(ctx context.Context) error { return fmt.Errorf("GraphQL errors: %s", strings.Join(errMessages, "; ")) } - if gqlResponse.Data == nil { - return fmt.Errorf("no data returned from identity query") + // 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 nil + 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) { - graphqlQuery := `{ actor { accounts { id name } } }` - - 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.APIKey) - 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 || actor == nil { - return nil, fmt.Errorf("invalid GraphQL response: missing actor") - } - - accountsData, ok := actor["accounts"].([]interface{}) - if !ok { - return []Account{}, nil + query := `{ actor { accounts { id name } } }` + var response struct { + Actor struct { + Accounts []Account `json:"accounts"` + } `json:"actor"` } - accounts := make([]Account, 0, len(accountsData)) - for _, accData := range accountsData { - m, ok := accData.(map[string]interface{}) - if !ok { - continue - } - - var id int64 - switch v := m["id"].(type) { - case float64: - id = int64(v) - case int64: - id = v - case string: - id, _ = strconv.ParseInt(v, 10, 64) - } - - name, _ := m["name"].(string) - - // FIXED: Added filter to skip invalid or empty accounts - if id == 0 || name == "" { - continue - } - - accounts = append(accounts, Account{ - ID: id, - Name: name, - }) + if err := c.doNerdGraphRequest(ctx, query, nil, &response); err != nil { + return nil, err } - - return accounts, nil + return response.Actor.Accounts, nil } type GraphQLRequest struct { @@ -334,7 +265,7 @@ func (c *Client) RunNRQLQuery(ctx context.Context, accountID int64, query string return nil, fmt.Errorf("failed to create NerdGraph request: %w", err) } - req.Header.Set("Api-Key", c.APIKey) + req.Header.Set("Api-Key", c.UserAPIKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") @@ -392,4 +323,4 @@ func (c *Client) RunNRQLQuery(ctx context.Context, accountID int64, query string } return &nrqlResponse, nil -} \ No newline at end of file +} diff --git a/pkg/integrations/newrelic/common.go b/pkg/integrations/newrelic/common.go index e4a6f6f840..6e5b9485c1 100644 --- a/pkg/integrations/newrelic/common.go +++ b/pkg/integrations/newrelic/common.go @@ -7,12 +7,10 @@ import ( const ( // US Region - restAPIBaseUS = "https://api.newrelic.com/v2" nerdGraphAPIBaseUS = "https://api.newrelic.com/graphql" metricsAPIBaseUS = "https://metric-api.newrelic.com/metric/v1" // EU Region - restAPIBaseEU = "https://api.eu.newrelic.com/v2" nerdGraphAPIBaseEU = "https://api.eu.newrelic.com/graphql" metricsAPIBaseEU = "https://metric-api.eu.newrelic.com/metric/v1" ) diff --git a/pkg/integrations/newrelic/example_output_report_metric.json b/pkg/integrations/newrelic/example_output_report_metric.json index c90e9e6605..fc1377d005 100644 --- a/pkg/integrations/newrelic/example_output_report_metric.json +++ b/pkg/integrations/newrelic/example_output_report_metric.json @@ -5,4 +5,4 @@ "timestamp": 1707552000000, "intervalMs": 0, "status": "202 Accepted" -} +} \ No newline at end of file diff --git a/pkg/integrations/newrelic/newrelic.go b/pkg/integrations/newrelic/newrelic.go index ea6a92d7e2..528a8e98f6 100644 --- a/pkg/integrations/newrelic/newrelic.go +++ b/pkg/integrations/newrelic/newrelic.go @@ -15,21 +15,16 @@ const installationInstructions = ` To set up New Relic integration: 1. **Select Region**: Choose your New Relic region (US or EU) -2. **Get API Key**: In New Relic, go to Account Settings > API Keys -3. **Create API Key**: Click "Create API key" and give it a name -4. **Copy API Key**: Copy the generated API key (you won't be able to see it again) -5. **Paste API Key**: Paste the API key in the field below +2. **Provide API Keys**: You can provide one or both keys depending on which components you need. -## Usage +## API Keys -- **Report Metric**: Use this action to send custom telemetry (Gauge, Count, Summary) to New Relic. -- **Run NRQL Query**: Use this action to fetch data from New Relic for decision making or reporting. +New Relic uses two different types of API keys for different purposes: -## Note +- **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. -The **Report Metric** action requires a **License Key** (Ingest - License) or an **API Key** (User) with ingest permissions. -If you use a User API Key, it must have the necessary permissions. A License Key is generally recommended for metric ingestion. -The **Run NRQL Query** action requires a **User API Key** (NRAK-) with query permissions. +You may provide both keys to enable all components, or just the key(s) for the components you need. ` func init() { @@ -39,8 +34,9 @@ func init() { type NewRelic struct{} type Configuration struct { - APIKey string `json:"apiKey" mapstructure:"apiKey"` - Site string `json:"site" mapstructure:"site"` + UserAPIKey string `json:"userApiKey" mapstructure:"userApiKey"` + LicenseKey string `json:"licenseKey" mapstructure:"licenseKey"` + Site string `json:"site" mapstructure:"site"` } type Metadata struct { @@ -86,12 +82,20 @@ func (n *NewRelic) Configuration() []configuration.Field { }, }, { - Name: "apiKey", - Label: "API Key", + Name: "userApiKey", + Label: "User API Key", Type: configuration.FieldTypeString, - Required: true, + 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: "New Relic API key from Account Settings > API Keys", + Description: "Ingest License Key for metric reporting. Get from Account Settings > API Keys.", }, } } @@ -115,40 +119,47 @@ func (n *NewRelic) Sync(ctx core.SyncContext) error { return fmt.Errorf("failed to decode configuration: %w", err) } - if config.APIKey == "" { - return fmt.Errorf("API key is required") + 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) - } - - // 1. Validate the API Key - err = client.ValidateAPIKey(context.Background()) - if err != nil { - return fmt.Errorf("failed to validate API key: %w", err) - } - - // 2. Fetch accounts so the UI can populate the \"Account\" dropdowns - 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{} - } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } - // 3. Save to Metadata - ctx.Integration.SetMetadata(Metadata{ - Accounts: accounts, - }) + // 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 + ctx.Integration.Ready() + return nil } - func (n *NewRelic) HandleRequest(ctx core.HTTPRequestContext) { // Webhooks will be handled by triggers } @@ -187,7 +198,6 @@ func (n *NewRelic) ListResources(resourceType string, ctx core.ListResourcesCont return resources, nil } - type NewRelicWebhookHandler struct{} func (h *NewRelicWebhookHandler) CompareConfig(a, b any) (bool, error) { @@ -197,6 +207,10 @@ func (h *NewRelicWebhookHandler) CompareConfig(a, b any) (bool, error) { 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 } @@ -208,4 +222,4 @@ func (h *NewRelicWebhookHandler) HandleWebhook(ctx core.WebhookRequestContext) ( func (h *NewRelicWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { return nil -} \ No newline at end of file +} diff --git a/pkg/integrations/newrelic/newrelic_test.go b/pkg/integrations/newrelic/newrelic_test.go index f7d8883adb..5d56854add 100644 --- a/pkg/integrations/newrelic/newrelic_test.go +++ b/pkg/integrations/newrelic/newrelic_test.go @@ -24,7 +24,7 @@ func jsonResponse(statusCode int, body string) *http.Response { func Test__NewRelic__Sync(t *testing.T) { n := &NewRelic{} - t.Run("missing API key -> error", func(t *testing.T) { + t.Run("no keys provided -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ "site": "US", @@ -32,33 +32,35 @@ func Test__NewRelic__Sync(t *testing.T) { } err := n.Sync(core.SyncContext{ - Configuration: map[string]any{}, + Configuration: map[string]any{"site": "US"}, Integration: integrationCtx, HTTP: &contexts.HTTPContext{}, }) require.Error(t, err) - assert.Contains(t, err.Error(), "API key is required") + assert.Contains(t, err.Error(), "at least one API key is required") }) - t.Run("empty API key -> error", func(t *testing.T) { + t.Run("empty keys -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "", + "userApiKey": "", + "licenseKey": "", + "site": "US", }, } err := n.Sync(core.SyncContext{ - Configuration: map[string]any{"apiKey": "", "site": "US"}, + Configuration: map[string]any{"userApiKey": "", "licenseKey": "", "site": "US"}, Integration: integrationCtx, HTTP: &contexts.HTTPContext{}, }) require.Error(t, err) - assert.Contains(t, err.Error(), "API key is required") + assert.Contains(t, err.Error(), "at least one API key is required") }) - t.Run("invalid API key -> error", func(t *testing.T) { + t.Run("invalid user API key -> error", func(t *testing.T) { httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ jsonResponse(http.StatusUnauthorized, `{ @@ -72,26 +74,26 @@ func Test__NewRelic__Sync(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "invalid-key", - "site": "US", + "userApiKey": "NRAK-invalid-key", + "site": "US", }, } err := n.Sync(core.SyncContext{ - Configuration: map[string]any{"apiKey": "invalid-key", "site": "US"}, + 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 API key") + 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, "invalid-key", httpCtx.Requests[0].Header.Get("Api-Key")) + assert.Equal(t, "NRAK-invalid-key", httpCtx.Requests[0].Header.Get("Api-Key")) }) - t.Run("valid API key -> sets ready", func(t *testing.T) { + t.Run("valid user API key -> validates and sets ready", func(t *testing.T) { httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ jsonResponse(http.StatusOK, `{ @@ -116,16 +118,16 @@ func Test__NewRelic__Sync(t *testing.T) { }, } - apiKey := "test-api-key" + userAPIKey := "NRAK-test-api-key" integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": apiKey, - "site": "US", + "userApiKey": userAPIKey, + "site": "US", }, } err := n.Sync(core.SyncContext{ - Configuration: map[string]any{"apiKey": apiKey, "site": "US"}, + Configuration: map[string]any{"userApiKey": userAPIKey, "site": "US"}, Integration: integrationCtx, HTTP: httpCtx, }) @@ -135,29 +137,104 @@ func Test__NewRelic__Sync(t *testing.T) { 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, "test-api-key", httpCtx.Requests[0].Header.Get("Api-Key")) + assert.Equal(t, "NRAK-test-api-key", httpCtx.Requests[0].Header.Get("Api-Key")) }) - t.Run("network error -> error", func(t *testing.T) { + 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{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "NRAK-test-key", + "site": "US", }, } err := n.Sync(core.SyncContext{ - Configuration: map[string]any{"apiKey": "test-key", "site": "US"}, + 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 API key") + 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) }) } @@ -215,7 +292,7 @@ func Test__NewRelic__ListResources(t *testing.T) { } func Test__Client__NewClient(t *testing.T) { - t.Run("missing API key -> error", func(t *testing.T) { + t.Run("no keys -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ "site": "US", @@ -225,27 +302,29 @@ func Test__Client__NewClient(t *testing.T) { _, err := NewClient(&contexts.HTTPContext{}, integrationCtx) require.Error(t, err) - assert.Contains(t, err.Error(), "API key is required") + assert.Contains(t, err.Error(), "at least one API key is required") }) - t.Run("empty API key -> error", func(t *testing.T) { + t.Run("empty keys -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "", + "userApiKey": "", + "licenseKey": "", + "site": "US", }, } _, err := NewClient(&contexts.HTTPContext{}, integrationCtx) require.Error(t, err) - assert.Contains(t, err.Error(), "API key is required") + assert.Contains(t, err.Error(), "at least one API key is required") }) - t.Run("valid API key -> success", func(t *testing.T) { + t.Run("user API key only -> success", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "NRAK-test-key", + "site": "US", }, } @@ -253,16 +332,51 @@ func Test__Client__NewClient(t *testing.T) { require.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, "test-key", client.APIKey) - assert.Equal(t, "https://api.newrelic.com/v2", client.BaseURL) + 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("valid EU API key -> success", func(t *testing.T) { + t.Run("EU region -> correct URLs", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "EU", + "userApiKey": "NRAK-test-key", + "site": "EU", }, } @@ -270,9 +384,8 @@ func Test__Client__NewClient(t *testing.T) { require.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, "test-key", client.APIKey) - assert.Equal(t, "https://api.eu.newrelic.com/v2", client.BaseURL) assert.Equal(t, "https://api.eu.newrelic.com/graphql", client.NerdGraphURL) + assert.Equal(t, "https://metric-api.eu.newrelic.com/metric/v1", client.MetricBaseURL) }) } @@ -294,7 +407,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "NRAK-test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -305,7 +418,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { 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, "NRAK-test-key", httpCtx.Requests[0].Header.Get("Api-Key")) }) t.Run("successful request with missing actor -> validates successfully", func(t *testing.T) { @@ -320,7 +433,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { } client := &Client{ - APIKey: "license-key", + UserAPIKey: "NRAK-test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -343,7 +456,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { } client := &Client{ - APIKey: strings.Repeat("x", 40), + UserAPIKey: strings.Repeat("x", 40), NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -368,7 +481,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { } client := &Client{ - APIKey: "invalid-key", + UserAPIKey: "invalid-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -391,7 +504,7 @@ func Test__Client__ValidateAPIKey(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "NRAK-test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -418,9 +531,13 @@ 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, "apiKey", config[1].Name) - assert.True(t, config[1].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 index e89b534785..e0b9b2cb2c 100644 --- a/pkg/integrations/newrelic/on_issue.go +++ b/pkg/integrations/newrelic/on_issue.go @@ -15,10 +15,8 @@ import ( type OnIssue struct{} type OnIssueConfiguration struct { - Priorities []string `json:"priorities" yaml:"priorities" mapstructure:"priorities"` - States []string `json:"states" yaml:"states" mapstructure:"states"` - Account string `json:"account" yaml:"account" mapstructure:"account"` - ManualAccountID string `json:"manualAccountId" yaml:"manualAccountId" mapstructure:"manualAccountId"` + Priorities []string `json:"priorities" yaml:"priorities" mapstructure:"priorities"` + States []string `json:"states" yaml:"states" mapstructure:"states"` } func (t *OnIssue) Name() string { @@ -75,26 +73,6 @@ func (t *OnIssue) Color() string { func (t *OnIssue) Configuration() []configuration.Field { return []configuration.Field{ - { - Name: "account", - Label: "Account", - Type: configuration.FieldTypeIntegrationResource, - Required: false, // Optional to prevent blocking - Description: "The New Relic account (optional for webhook)", - TypeOptions: &configuration.TypeOptions{ - Resource: &configuration.ResourceTypeOptions{ - Type: "account", - }, - }, - }, - { - Name: "manualAccountId", - Label: "Manual Account ID", - Type: configuration.FieldTypeString, - Required: false, - Description: "Manually enter Account ID if dropdown fails", - Placeholder: "1234567", - }, { Name: "priorities", Label: "Priorities", @@ -138,6 +116,12 @@ type OnIssueMetadata struct { } 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 @@ -145,7 +129,7 @@ func (t *OnIssue) Setup(ctx core.TriggerContext) error { // 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) @@ -299,4 +283,4 @@ func allowedState(state string, allowed []string) bool { func (t *OnIssue) Cleanup(ctx core.TriggerContext) error { return nil -} \ No newline at end of file +} diff --git a/pkg/integrations/newrelic/on_issue_test.go b/pkg/integrations/newrelic/on_issue_test.go index 6c9151ae4f..b267e88b69 100644 --- a/pkg/integrations/newrelic/on_issue_test.go +++ b/pkg/integrations/newrelic/on_issue_test.go @@ -22,10 +22,14 @@ func Test__OnIssue__Setup(t *testing.T) { "priorities": []string{"CRITICAL"}, "states": []string{"ACTIVATED"}, }, - Integration: &contexts.IntegrationContext{}, - Webhook: &contexts.WebhookContext{}, - Metadata: metadataCtx, - Logger: log.NewEntry(log.New()), + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "userApiKey": "NRAK-TEST", + }, + }, + Webhook: &contexts.WebhookContext{}, + Metadata: metadataCtx, + Logger: log.NewEntry(log.New()), } err := trigger.Setup(ctx) @@ -45,8 +49,13 @@ func Test__OnIssue__Setup(t *testing.T) { } ctx := core.TriggerContext{ Configuration: map[string]any{}, - Metadata: metadataCtx, - Logger: log.NewEntry(log.New()), + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "userApiKey": "NRAK-TEST", + }, + }, + Metadata: metadataCtx, + Logger: log.NewEntry(log.New()), } err := trigger.Setup(ctx) @@ -104,7 +113,7 @@ func Test__OnIssue__HandleWebhook(t *testing.T) { 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"]) diff --git a/pkg/integrations/newrelic/report_metric.go b/pkg/integrations/newrelic/report_metric.go index ef72457206..fa1d91c8c0 100644 --- a/pkg/integrations/newrelic/report_metric.go +++ b/pkg/integrations/newrelic/report_metric.go @@ -16,12 +16,12 @@ const ReportMetricPayloadType = "newrelic.metric" type ReportMetric struct{} type ReportMetricSpec struct { - MetricName string `json:"metricName" yaml:"metricName"` - MetricType string `json:"metricType" yaml:"metricType"` - Value float64 `json:"value" yaml:"value"` - Timestamp int64 `json:"timestamp" yaml:"timestamp"` - IntervalMs int64 `json:"intervalMs" yaml:"intervalMs"` - Attributes map[string]any `json:"attributes" yaml:"attributes"` + 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 { @@ -66,8 +66,6 @@ func (c *ReportMetric) OutputChannels(configuration any) []core.OutputChannel { return []core.OutputChannel{core.DefaultOutputChannel} } - - func (c *ReportMetric) Configuration() []configuration.Field { return []configuration.Field{ { @@ -128,6 +126,13 @@ func (c *ReportMetric) Configuration() []configuration.Field { } 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) @@ -194,20 +199,20 @@ func (c *ReportMetric) Execute(ctx core.ExecutionContext) error { 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}, - ) + 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 { diff --git a/pkg/integrations/newrelic/report_metric_test.go b/pkg/integrations/newrelic/report_metric_test.go index 50908fb3d6..5efc24287b 100644 --- a/pkg/integrations/newrelic/report_metric_test.go +++ b/pkg/integrations/newrelic/report_metric_test.go @@ -60,6 +60,13 @@ func TestReportMetric_Configuration(t *testing.T) { 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{ @@ -67,6 +74,7 @@ func TestReportMetric_Setup_IntervalValidation(t *testing.T) { "metricType": "gauge", "value": 1.0, }, + Integration: integrationCtx, }) require.NoError(t, err) @@ -80,6 +88,7 @@ func TestReportMetric_Setup_IntervalValidation(t *testing.T) { "value": 1.0, // intervalMs missing }, + Integration: integrationCtx, }) require.Error(t, err) @@ -94,6 +103,7 @@ func TestReportMetric_Setup_IntervalValidation(t *testing.T) { "value": 1.0, // intervalMs missing }, + Integration: integrationCtx, }) require.Error(t, err) @@ -108,10 +118,30 @@ func TestReportMetric_Setup_IntervalValidation(t *testing.T) { "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) { @@ -127,8 +157,7 @@ func TestClient_ReportMetric(t *testing.T) { } client := &Client{ - APIKey: "test-key", - BaseURL: "https://api.newrelic.com/v2", + LicenseKey: "test-key", MetricBaseURL: "https://metric-api.newrelic.com/metric/v1", http: httpCtx, } @@ -171,7 +200,7 @@ func TestClient_ReportMetric(t *testing.T) { assert.Equal(t, float64(42.5), sentBatch[0].Metrics[0].Value) }) - t.Run("User API Key (NRAK) request -> uses Api-Key header", func(t *testing.T) { + t.Run("fallback to UserAPIKey when no LicenseKey -> uses Api-Key header", func(t *testing.T) { httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ { @@ -183,8 +212,7 @@ func TestClient_ReportMetric(t *testing.T) { } client := &Client{ - APIKey: "NRAK-test-key", - BaseURL: "https://api.newrelic.com/v2", + UserAPIKey: "NRAK-test-key", MetricBaseURL: "https://metric-api.newrelic.com/metric/v1", http: httpCtx, } @@ -224,8 +252,7 @@ func TestClient_ReportMetric(t *testing.T) { } client := &Client{ - APIKey: "test-key", - BaseURL: "https://api.newrelic.com/v2", + LicenseKey: "test-key", MetricBaseURL: "https://metric-api.newrelic.com/metric/v1", http: httpCtx, } @@ -247,7 +274,7 @@ func TestClient_ReportMetric(t *testing.T) { 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 @@ -269,8 +296,7 @@ func TestClient_ReportMetric(t *testing.T) { } client := &Client{ - APIKey: "test-key", - BaseURL: "https://api.newrelic.com/v2", + LicenseKey: "test-key", MetricBaseURL: "https://metric-api.newrelic.com/metric/v1", http: httpCtx, } @@ -305,8 +331,7 @@ func TestClient_ReportMetric(t *testing.T) { } client := &Client{ - APIKey: "eu-test-key", - BaseURL: "https://api.eu.newrelic.com/v2", + LicenseKey: "eu-test-key", MetricBaseURL: "https://metric-api.eu.newrelic.com/metric/v1", http: httpCtx, } @@ -347,8 +372,8 @@ func TestReportMetric_Execute_SetsIntervalMsForCountAndSummary(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "licenseKey": "test-key", + "site": "US", }, } @@ -393,8 +418,8 @@ func TestReportMetric_Execute_SetsIntervalMsForCountAndSummary(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "licenseKey": "test-key", + "site": "US", }, } @@ -420,8 +445,8 @@ 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{ - "apiKey": "test-key", - "site": "US", + "licenseKey": "test-key", + "site": "US", }, } @@ -434,8 +459,8 @@ func TestNewClient_MetricBaseURL(t *testing.T) { t.Run("EU region -> sets EU metric URL", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "EU", + "licenseKey": "test-key", + "site": "EU", }, } diff --git a/pkg/integrations/newrelic/repro_test.go b/pkg/integrations/newrelic/repro_test.go index 07bc9cf5d4..7e324d9b13 100644 --- a/pkg/integrations/newrelic/repro_test.go +++ b/pkg/integrations/newrelic/repro_test.go @@ -1,6 +1,9 @@ package newrelic import ( + "io" + "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -26,21 +29,40 @@ func TestRunNRQLQuery_Setup_Repro(t *testing.T) { }, expectError: false, }, - { - name: "manual account id fallback", - configuration: map[string]any{ - // account field is missing/nil, simulating UI issue - "manualAccountId": "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{}, } diff --git a/pkg/integrations/newrelic/run_nrql_query.go b/pkg/integrations/newrelic/run_nrql_query.go index aaee71d02d..c99ce92db7 100644 --- a/pkg/integrations/newrelic/run_nrql_query.go +++ b/pkg/integrations/newrelic/run_nrql_query.go @@ -19,10 +19,9 @@ type RunNRQLQuery struct{} // RunNRQLQuerySpec defines the configuration for the Run NRQL Query component. type RunNRQLQuerySpec struct { - Account string `json:"account" mapstructure:"account"` - ManualAccountID string `json:"manualAccountId" mapstructure:"manualAccountId"` - Query string `json:"query" mapstructure:"query"` - Timeout int `json:"timeout" mapstructure:"timeout"` + Account string `json:"account" mapstructure:"account"` + Query string `json:"query" mapstructure:"query"` + Timeout int `json:"timeout" mapstructure:"timeout"` } type RunNRQLQueryPayload struct { @@ -33,6 +32,12 @@ type RunNRQLQueryPayload struct { 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" } @@ -57,7 +62,7 @@ func (c *RunNRQLQuery) Documentation() string { ## Configuration -- **Account ID**: The New Relic account ID to query against (required) +- **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) @@ -101,7 +106,7 @@ func (c *RunNRQLQuery) Configuration() []configuration.Field { Name: "account", Label: "Account", Type: configuration.FieldTypeIntegrationResource, - Required: false, // Changed to false to allow manual override + Required: true, Description: "The New Relic account to query", TypeOptions: &configuration.TypeOptions{ Resource: &configuration.ResourceTypeOptions{ @@ -109,14 +114,6 @@ func (c *RunNRQLQuery) Configuration() []configuration.Field { }, }, }, - { - Name: "manualAccountId", - Label: "Manual Account ID", - Type: configuration.FieldTypeString, - Required: false, - Description: "Manually enter Account ID if dropdown fails", - Placeholder: "1234567", - }, { Name: "query", Label: "NRQL Query", @@ -138,28 +135,27 @@ func (c *RunNRQLQuery) Configuration() []configuration.Field { } 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) } - // Set manual: true in metadata to ensure UI handles generic components correctly - if err := ctx.Metadata.Set(map[string]any{"manual": true}); err != nil { - return fmt.Errorf("failed to set metadata: %w", err) - } - - accountID := spec.Account - if spec.ManualAccountID != "" { - accountID = spec.ManualAccountID - } + accountIDStr := spec.Account - if accountID == "" { - return fmt.Errorf("account is required (select from dropdown or use Manual Account ID)") + if accountIDStr == "" { + return fmt.Errorf("account is required (select from dropdown)") } // Guard: reject unresolved template tags early - if isUnresolvedTemplate(accountID) { - return fmt.Errorf("account ID contains unresolved template variable: %s — configure the upstream trigger first", accountID) + if isUnresolvedTemplate(accountIDStr) { + return fmt.Errorf("account ID contains unresolved template variable: %s — configure the upstream trigger first", accountIDStr) } if spec.Query == "" { @@ -175,6 +171,41 @@ func (c *RunNRQLQuery) Setup(ctx core.SetupContext) error { 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 } @@ -191,9 +222,6 @@ func (c *RunNRQLQuery) Execute(ctx core.ExecutionContext) error { // Extract account ID from configuration accountIDStr := spec.Account - if spec.ManualAccountID != "" { - accountIDStr = spec.ManualAccountID - } // Fallback: try to resolve from upstream trigger event data (ctx.Data) if accountIDStr == "" { @@ -325,7 +353,7 @@ func extractResourceID(v any) string { case float64: return strconv.FormatFloat(n, 'f', -1, 64) case float32: - return strconv.FormatFloat(float64(n), 'f', -1, 64) + return strconv.FormatFloat(float64(n), 'f', -1, 32) } // Handle maps @@ -342,4 +370,4 @@ func extractResourceID(v any) string { // Fallback to string representation return fmt.Sprintf("%v", v) -} \ No newline at end of file +} diff --git a/pkg/integrations/newrelic/run_nrql_query_test.go b/pkg/integrations/newrelic/run_nrql_query_test.go index 6f03e76333..dfb7e45c78 100644 --- a/pkg/integrations/newrelic/run_nrql_query_test.go +++ b/pkg/integrations/newrelic/run_nrql_query_test.go @@ -42,7 +42,7 @@ func TestRunNRQLQuery_Configuration(t *testing.T) { } require.NotNil(t, accountIDField) - assert.False(t, *accountIDField) + assert.True(t, *accountIDField) // account is now required require.NotNil(t, queryField) assert.True(t, *queryField) } @@ -84,7 +84,7 @@ func TestClient_RunNRQLQuery_Success(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -147,7 +147,7 @@ func TestClient_RunNRQLQuery_Success(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -184,7 +184,7 @@ func TestClient_RunNRQLQuery_Success(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -220,7 +220,7 @@ func TestClient_RunNRQLQuery_Success(t *testing.T) { } client := &Client{ - APIKey: "eu-test-key", + UserAPIKey: "eu-test-key", NerdGraphURL: "https://api.eu.newrelic.com/graphql", http: httpCtx, } @@ -263,7 +263,7 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -295,7 +295,7 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { } client := &Client{ - APIKey: "invalid-key", + UserAPIKey: "invalid-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -330,7 +330,7 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -357,7 +357,7 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -385,7 +385,7 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { } client := &Client{ - APIKey: "test-key", + UserAPIKey: "test-key", NerdGraphURL: "https://api.newrelic.com/graphql", http: httpCtx, } @@ -401,17 +401,51 @@ func TestClient_RunNRQLQuery_Errors(t *testing.T) { 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, + "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) { @@ -420,6 +454,12 @@ func TestRunNRQLQuery_Setup(t *testing.T) { Configuration: map[string]any{ "query": "SELECT count(*) FROM Transaction", }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "userApiKey": "test-key", + "site": "US", + }, + }, Metadata: &contexts.MetadataContext{}, } @@ -434,6 +474,12 @@ func TestRunNRQLQuery_Setup(t *testing.T) { Configuration: map[string]any{ "account": "1234567", }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "userApiKey": "test-key", + "site": "US", + }, + }, Metadata: &contexts.MetadataContext{}, } @@ -447,8 +493,14 @@ func TestRunNRQLQuery_Setup(t *testing.T) { ctx := core.SetupContext{ Configuration: map[string]any{ "account": "1234567", - "query": "SELECT count(*) FROM Transaction", - "timeout": 150, // exceeds max of 120 + "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{}, } @@ -457,18 +509,69 @@ func TestRunNRQLQuery_Setup(t *testing.T) { 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", }, - Metadata: &contexts.MetadataContext{}, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, } err := component.Setup(ctx) require.Error(t, err) @@ -482,7 +585,8 @@ func TestRunNRQLQuery_Setup_TemplateGuard(t *testing.T) { "account": "1234567", "query": "{{nrql_query}}", }, - Metadata: &contexts.MetadataContext{}, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, } err := component.Setup(ctx) require.Error(t, err) @@ -502,8 +606,8 @@ func TestRunNRQLQuery_Execute_TemplateGuard(t *testing.T) { }, Integration: &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "test-key", + "site": "US", }, }, } @@ -521,8 +625,8 @@ func TestRunNRQLQuery_Execute_TemplateGuard(t *testing.T) { }, Integration: &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "test-key", + "site": "US", }, }, } @@ -571,8 +675,8 @@ func TestRunNRQLQuery_Execute_DataFallback(t *testing.T) { HTTP: httpCtx, Integration: &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "test-key", + "site": "US", }, }, ExecutionState: executionState, @@ -590,7 +694,7 @@ func TestRunNRQLQuery_Execute_DataFallback(t *testing.T) { func TestRunNRQLQuery_Execute(t *testing.T) { t.Run("string account ID -> success", func(t *testing.T) { component := &RunNRQLQuery{} - + responseJSON := `{ "data": { "actor": { @@ -615,13 +719,13 @@ func TestRunNRQLQuery_Execute(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "test-key", + "site": "US", }, } executionState := &contexts.ExecutionStateContext{} - + ctx := core.ExecutionContext{ Configuration: map[string]any{ "account": "1234567", @@ -652,8 +756,8 @@ func TestRunNRQLQuery_Execute(t *testing.T) { }, Integration: &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-key", - "site": "US", + "userApiKey": "test-key", + "site": "US", }, }, }