Skip to content

Commit 4e9cb5d

Browse files
Merge pull request #4 from asomensari-es/feat/legacy-conditions-rest-fetcher
Feat/legacy conditions rest fetcher
2 parents 9f435a1 + c3431e9 commit 4e9cb5d

19 files changed

Lines changed: 616 additions & 607 deletions

README.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,6 @@ nr-tf-util --mode audit --skip synthetics_monitors
186186

187187
# ID-reference checks only (skip all config health checks)
188188
nr-tf-util --mode audit \
189-
--skip disabled_conditions \
190189
--skip legacy_conditions \
191190
--skip uncovered_alert_policies
192191

@@ -247,7 +246,7 @@ terraform apply
247246
| `--region` | `US` | NerdGraph region: `US`, `EU`, `FedRAMP`, or `JP`. |
248247
| `--rate-limit` | `25` | Max NerdGraph API requests per second. |
249248
| `--max-retries` | `3` | Max retries for failed API calls. |
250-
| `--timeout` | `5m` | Per-account scan timeout (Go duration string, e.g. `2m30s`). |
249+
| `--timeout` | `15m` | Per-account scan timeout (Go duration string, e.g. `2m30s`). |
251250
| `--verbose` | `false` | Show additional detail in progress output. |
252251
| `--quiet` | `false` | Suppress all non-error output. |
253252

@@ -289,17 +288,18 @@ terraform apply
289288
| Dashboards | `dashboards` | NRQL queries in every widget (`rawConfiguration`) |
290289
| NRQL alert conditions | `nrql_conditions` | Condition NRQL query string |
291290
| Workloads | `workloads` | Static entity GUID list + dynamic entity filter expressions |
292-
| Service levels | `service_levels` | Monitored entity GUID + events WHERE clauses |
291+
| Service levels | `service_levels` | Monitored entity GUID (via entity tag) |
293292
| Workflows | `workflows` | Issue filter predicate values |
294293
| Synthetic monitors | `synthetics_monitors` | Script body of SCRIPT_API and SCRIPT_BROWSER monitors |
294+
| Legacy metric conditions | `legacy_conditions` | Monitored entity IDs (`appId`, `browserId`, `mobileId`) — fetched via REST API v2 |
295295

296296
#### Alert configuration health checks
297297

298298
| Check | `--skip` value | What is flagged | Rationale |
299299
|---|---|---|---|
300300
| Disabled NRQL conditions | `disabled_conditions` | Conditions with `enabled=false` | A disabled condition never fires. It should be re-enabled if monitoring is still needed, or removed to keep the policy readable. |
301301
| Disabled synthetic monitors | `synthetics_monitors` | Monitors with `status=DISABLED` (any monitor type) | A disabled monitor produces no results and incurs no checks-per-month cost, but it clutters the monitors list and may give a false sense of coverage. |
302-
| Legacy metric conditions | `legacy_conditions` | APM/Browser/Mobile metric conditions (non-NRQL) | These predate NRQL alerting and are no longer the recommended format. NRQL conditions offer finer control, support streaming alerts, and will receive future New Relic improvements; legacy conditions will not. |
302+
| Legacy metric conditions | `legacy_conditions` | APM/Browser/Mobile metric conditions (non-NRQL) | These predate NRQL alerting and are no longer the recommended format. NRQL conditions offer finer control, support streaming alerts, and will receive future New Relic improvements; legacy conditions will not. Scanned via the REST API v2 (same User API key; no extra credential needed). |
303303
| Policies with no conditions | `uncovered_alert_policies` | Alert policies that have zero conditions | An empty policy can never generate an alert. It is dead configuration that clutters the policy list and may give a false sense of coverage. |
304304
| Policies with no notification | `uncovered_alert_policies` | Policies with at least one enabled condition but no aiWorkflow routing their alerts | When a condition fires, New Relic creates a violation and opens an issue — but if no workflow routes that issue to a destination, nobody is notified. This is one of the most common causes of silent alerting failures. |
305305

@@ -536,12 +536,12 @@ After `apply`, your New Relic resources are under Terraform management.
536536

537537
## Regions
538538

539-
| Value | NerdGraph endpoint | Notes |
540-
|-----------|-------------------------------------------|--------------------------------------------------------|
541-
| `US` | `https://api.newrelic.com/graphql` | Default |
542-
| `EU` | `https://api.eu.newrelic.com/graphql` | |
543-
| `FedRAMP` | `https://gov-api.newrelic.com/graphql` | Generates `nerdgraph_api_url` override in provider.tf |
544-
| `JP` | `https://api.jp.newrelic.com/graphql` | Japanese datacenter |
539+
| Value | NerdGraph endpoint | REST API v2 base URL | Notes |
540+
|-----------|-------------------------------------------|---------------------------------------|--------------------------------------------------------|
541+
| `US` | `https://api.newrelic.com/graphql` | `https://api.newrelic.com` | Default |
542+
| `EU` | `https://api.eu.newrelic.com/graphql` | `https://api.eu.newrelic.com` | |
543+
| `FedRAMP` | `https://gov-api.newrelic.com/graphql` | `https://gov-api.newrelic.com` | Generates `nerdgraph_api_url` override in provider.tf |
544+
| `JP` | `https://api.jp.newrelic.com/graphql` | `https://api.jp.newrelic.com` | Japanese datacenter |
545545

546546
---
547547

@@ -550,17 +550,23 @@ After `apply`, your New Relic resources are under Terraform management.
550550
The tool requires a **User API key** (`NRAK-...`).
551551

552552
- **Audit and import modes** — read access to the accounts you want to scan.
553+
The same key is used for both NerdGraph queries and REST API v2 calls (legacy
554+
conditions). No additional credential is needed.
553555
- **Cleanup mode** — write access is required in addition to read access, as the
554556
tool calls NerdGraph delete mutations.
555557

556558
---
557559

558560
## Rate limiting and retries
559561

560-
- A token-bucket rate limiter (default 25 req/s) prevents hitting New Relic's API limits.
562+
- A token-bucket rate limiter (default 25 req/s) prevents hitting New Relic's NerdGraph
563+
API limits.
561564
- The underlying `newrelic-client-go/v2` library retries transient failures automatically
562565
using exponential back-off (`hashicorp/go-retryablehttp`).
563566
- Adjust `--rate-limit` if you have many accounts or see throttling errors.
567+
- Legacy condition scanning makes one REST API v2 call per alert policy. Accounts with
568+
many policies may take additional time during the `legacy_conditions` check. Use
569+
`--skip legacy_conditions` to skip this check if speed is a priority.
564570

565571
---
566572

cmd/main.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func main() {
4848
outDir = flag.String("out", "./output", "Root directory for generated files (import mode only)")
4949
rateLimit = flag.Int("rate-limit", 25, "Max API requests per second")
5050
maxRetries = flag.Int("max-retries", 3, "Max retries for failed API calls")
51-
timeout = flag.Duration("timeout", 5*time.Minute, "Per-account scan timeout")
51+
timeout = flag.Duration("timeout", 15*time.Minute, "Per-account scan timeout")
5252
dryRun = flag.Bool("dry-run", false, "Print what would be written without writing files (import mode only)")
5353
verbose = flag.Bool("verbose", false, "Show per-fetcher timing details")
5454
quiet = flag.Bool("quiet", false, "Suppress all non-error output")
@@ -100,9 +100,10 @@ FILTERING
100100
service_levels workloads
101101
102102
Audit mode asset types:
103-
dashboards nrql_conditions
103+
dashboards nrql_conditions (covers disabled checks too)
104104
workloads service_levels
105105
workflows synthetics_monitors
106+
legacy_conditions (scanned via REST API v2; same key as NerdGraph)
106107
107108
AUDIT MODE FLAGS
108109
--csv <file> Write findings to a CSV file (e.g. --csv findings.csv)
@@ -124,7 +125,7 @@ BEHAVIOUR
124125
--quiet Suppress all non-error output
125126
--rate-limit <n> Max API requests per second (default: 25)
126127
--max-retries <n> Max retries on API failure (default: 3)
127-
--timeout <duration> Per-account timeout, e.g. 2m30s (default: 5m)
128+
--timeout <duration> Per-account timeout, e.g. 2m30s (default: 15m)
128129
129130
EXAMPLES — audit mode
130131
export NEW_RELIC_API_KEY=NRAK-...
@@ -135,7 +136,7 @@ EXAMPLES — audit mode
135136
# Audit a single account and export findings to CSV
136137
nr-tf-util --mode audit --account-id 2883194 --csv findings.csv
137138
138-
# Skip legacy-condition and uncovered-policy checks
139+
# Skip the legacy-conditions notice and uncovered-policy checks
139140
nr-tf-util --mode audit --skip legacy_conditions --skip uncovered_alert_policies
140141
141142
EXAMPLES — cleanup mode
@@ -318,11 +319,13 @@ NEXT STEPS (after import mode)
318319
}
319320

320321
// ── Mode branch ────────────────────────────────────────────────────────────
322+
restClient := newNRRESTClient(key, reg.RESTURL)
323+
321324
switch *mode {
322325
case "import":
323326
runImport(ctx, ng, limiter, accounts, skipSet, reg, *outDir, *dryRun, *verbose, *timeout, Version, printf, out)
324327
case "audit":
325-
runAudit(ctx, ng, limiter, accounts, skipSet, *timeout, printf, *csvOut)
328+
runAudit(ctx, ng, limiter, accounts, skipSet, *timeout, printf, *csvOut, restClient)
326329
}
327330
}
328331

@@ -440,8 +443,9 @@ func runAudit(
440443
timeout time.Duration,
441444
printf func(string, ...interface{}),
442445
csvPath string,
446+
restClient audit.LegacyConditionsClient,
443447
) {
444-
auditor := audit.New(ng, limiter, audit.Options{Skip: skipSet}, os.Stdout)
448+
auditor := audit.New(ng, restClient, limiter, audit.Options{Skip: skipSet}, os.Stdout)
445449
resolver := audit.NewResolver(ng, limiter)
446450

447451
start := time.Now()

cmd/rest_client.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
11+
"github.com/asomensari-es/nr-tf-util/internal/audit"
12+
)
13+
14+
// nrRESTClient implements audit.LegacyConditionsClient against the
15+
// New Relic REST API v2. The same User API key used for NerdGraph works here.
16+
type nrRESTClient struct {
17+
httpClient *http.Client
18+
apiKey string
19+
baseURL string // e.g. "https://api.newrelic.com"
20+
}
21+
22+
func newNRRESTClient(apiKey, baseURL string) *nrRESTClient {
23+
return &nrRESTClient{
24+
httpClient: &http.Client{},
25+
apiKey: apiKey,
26+
baseURL: baseURL,
27+
}
28+
}
29+
30+
// ListLegacyConditions fetches all non-NRQL conditions for policyID, handling
31+
// REST API v2 page-based pagination internally.
32+
func (c *nrRESTClient) ListLegacyConditions(ctx context.Context, policyID int) ([]audit.LegacyCondition, error) {
33+
type restPage struct {
34+
Conditions []audit.LegacyCondition `json:"conditions"`
35+
}
36+
37+
var all []audit.LegacyCondition
38+
for page := 1; ; page++ {
39+
params := url.Values{
40+
"policy_id": {fmt.Sprintf("%d", policyID)},
41+
"page": {fmt.Sprintf("%d", page)},
42+
}
43+
reqURL := c.baseURL + "/v2/alerts_conditions.json?" + params.Encode()
44+
45+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
46+
if err != nil {
47+
return nil, err
48+
}
49+
req.Header.Set("Api-Key", c.apiKey)
50+
req.Header.Set("Accept", "application/json")
51+
52+
resp, err := c.httpClient.Do(req)
53+
if err != nil {
54+
return nil, err
55+
}
56+
body, readErr := io.ReadAll(resp.Body)
57+
resp.Body.Close()
58+
if readErr != nil {
59+
return nil, readErr
60+
}
61+
62+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
63+
// Policy not found in REST API — it was created via NerdGraph and has no
64+
// legacy conditions. Treat as empty rather than an error.
65+
break
66+
}
67+
if resp.StatusCode != http.StatusOK {
68+
return nil, fmt.Errorf("REST API returned %d: %s", resp.StatusCode, string(body))
69+
}
70+
71+
var pageResp restPage
72+
if err := json.Unmarshal(body, &pageResp); err != nil {
73+
return nil, err
74+
}
75+
if len(pageResp.Conditions) == 0 {
76+
break
77+
}
78+
all = append(all, pageResp.Conditions...)
79+
}
80+
return all, nil
81+
}

internal/audit/auditor.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,20 @@ type Options struct {
5050
// Auditor orchestrates all audit fetchers across accounts.
5151
type Auditor struct {
5252
ng resources.NerdGrapher
53+
rest LegacyConditionsClient // nil → legacy_conditions returns a static notice
5354
limiter *ratelimit.Limiter
5455
opts Options
5556
out io.Writer
5657
}
5758

58-
// New creates an Auditor. out receives progress lines; pass nil to silence.
59-
func New(ng resources.NerdGrapher, limiter *ratelimit.Limiter, opts Options, out io.Writer) *Auditor {
59+
// New creates an Auditor. rest may be nil when REST API access is unavailable,
60+
// in which case legacy_conditions emits a static notice instead of scanning.
61+
// out receives progress lines; pass nil to silence.
62+
func New(ng resources.NerdGrapher, rest LegacyConditionsClient, limiter *ratelimit.Limiter, opts Options, out io.Writer) *Auditor {
6063
if out == nil {
6164
out = io.Discard
6265
}
63-
return &Auditor{ng: ng, limiter: limiter, opts: opts, out: out}
66+
return &Auditor{ng: ng, rest: rest, limiter: limiter, opts: opts, out: out}
6467
}
6568

6669
// Audit runs all enabled fetchers for each account in sequence (fetchers run
@@ -87,7 +90,7 @@ type fetchResult struct {
8790
// auditOne runs all enabled fetchers concurrently for a single account.
8891
func (a *Auditor) auditOne(ctx context.Context, acct resources.Account) (*AuditResult, error) {
8992
rlng := &rateLimitedNG{ng: a.ng, limiter: a.limiter}
90-
fetchers := AllFetchers()
93+
fetchers := AllFetchers(a.rest)
9194

9295
ch := make(chan fetchResult, len(fetchers))
9396
var wg sync.WaitGroup
@@ -150,18 +153,18 @@ func (a *Auditor) auditOne(ctx context.Context, acct resources.Account) (*AuditR
150153
}
151154

152155
// AllFetchers returns every registered AuditFetcher in display order.
153-
// Add new asset types here.
154-
func AllFetchers() []AuditFetcher {
156+
// rest is passed to legacyConditionsAuditFetcher; pass nil when REST access is
157+
// unavailable and the fetcher will emit a static notice instead.
158+
func AllFetchers(rest LegacyConditionsClient) []AuditFetcher {
155159
return []AuditFetcher{
156160
&dashboardsAuditFetcher{},
157161
&conditionsAuditFetcher{},
158-
&disabledConditionsAuditFetcher{},
159-
&legacyConditionsAuditFetcher{},
160162
&workloadsAuditFetcher{},
161163
&serviceLevelsAuditFetcher{},
162164
&workflowsAuditFetcher{},
163165
&syntheticsAuditFetcher{},
164166
&uncoveredPoliciesAuditFetcher{},
167+
&legacyConditionsAuditFetcher{rest: rest},
165168
}
166169
}
167170

internal/audit/fetch_conditions.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"github.com/asomensari-es/nr-tf-util/internal/resources"
88
)
99

10-
// conditionsAuditFetcher scans all NRQL alert conditions for hardcoded IDs
11-
// in their NRQL query strings.
10+
// conditionsAuditFetcher scans all NRQL alert conditions in a single pass,
11+
// emitting findings for both hardcoded ID references and disabled conditions.
12+
// Combining both checks avoids two full nrqlConditionsSearch sweeps — a
13+
// meaningful saving on large accounts that have thousands of conditions.
1214
type conditionsAuditFetcher struct{}
1315

1416
func (f *conditionsAuditFetcher) Name() string { return "nrql_conditions" }
@@ -23,6 +25,7 @@ query($accountId: Int!, $cursor: String) {
2325
nrqlConditions {
2426
id
2527
name
28+
enabled
2629
nrql { query }
2730
}
2831
nextCursor
@@ -38,9 +41,10 @@ query($accountId: Int!, $cursor: String) {
3841
Alerts struct {
3942
NrqlConditionsSearch struct {
4043
NrqlConditions []struct {
41-
ID string `json:"id"`
42-
Name string `json:"name"`
43-
NRQL struct {
44+
ID string `json:"id"`
45+
Name string `json:"name"`
46+
Enabled bool `json:"enabled"`
47+
NRQL struct {
4448
Query string `json:"query"`
4549
} `json:"nrql"`
4650
} `json:"nrqlConditions"`
@@ -60,11 +64,24 @@ query($accountId: Int!, $cursor: String) {
6064
return "", err
6165
}
6266
for _, c := range resp.Actor.Account.Alerts.NrqlConditionsSearch.NrqlConditions {
67+
assetID := fmt.Sprintf("%d:%s", accountID, c.ID)
68+
69+
if !c.Enabled {
70+
findings = append(findings, Finding{
71+
AssetType: "nrql_condition",
72+
AssetName: c.Name,
73+
AssetID: assetID,
74+
NRQLQuery: c.NRQL.Query,
75+
Location: "condition configuration",
76+
Suggestion: "Condition is disabled — enable it or remove it to reduce policy noise",
77+
})
78+
}
79+
6380
for _, m := range ScanText(c.NRQL.Query, "condition query") {
6481
findings = append(findings, Finding{
6582
AssetType: "nrql_condition",
6683
AssetName: c.Name,
67-
AssetID: fmt.Sprintf("%d:%s", accountID, c.ID),
84+
AssetID: assetID,
6885
NRQLQuery: c.NRQL.Query,
6986
Location: m.Location,
7087
IDType: m.IDType,

0 commit comments

Comments
 (0)