@cryptotaxi247 / netdata-1 / commits / a7fd47fa2

feat(go.d/azure_monitor): add per-profile resource filters (#22132)

Ilya Mashchenko committed Apr 3, 2026 at 23:59 UTC a7fd47fa2b9189c2b6ee94a38cd28e80ce42c971
57 files changed +1689 -552
src/go/plugin/go.d/collector/azure_monitor/azureprofiles/catalog.go
+45 -99
@@ -25,10 +25,14 @@ const (
25
26 type Catalog struct {
27 byBaseName map[string]Profile
28 - byID map[string]Profile
28 stockProfileBaseNames map[string]struct{}
29 }
30
31 +type ResolvedProfile struct {
32 + Name string
33 + Config Profile
34 +}
35 +
36 type catalogEntry struct {
37 Config Profile
38 Path string
@@ -60,7 +64,6 @@ func LoadFromDefaultDirs() (Catalog, error) {
64 func LoadFromDirs(specs []DirSpec) (Catalog, error) {
65 catalog := Catalog{
66 byBaseName: make(map[string]Profile),
63 - byID: make(map[string]Profile),
67 stockProfileBaseNames: make(map[string]struct{}),
68 }
69 seen := make(map[string]catalogEntry)
@@ -93,14 +96,14 @@ func LoadFromDirs(specs []DirSpec) (Catalog, error) {
96 return nil
97 }
98
96 - cfg, err := loadProfileFile(path)
97 - if err != nil {
98 - return err
99 + baseName := strings.TrimSpace(profileBaseName(path))
100 + if !IsValidProfileName(baseName) {
101 + return fmt.Errorf("profile %q: basename must match %q", path, reIdentityID.String())
102 }
103
101 - baseName := normalizeKey(profileBaseName(path))
102 - if baseName == "" {
103 - return fmt.Errorf("profile %q: decoded empty basename", path)
104 + cfg, err := loadProfileFile(path, baseName)
105 + if err != nil {
106 + return err
107 }
108 if spec.IsStock {
109 catalog.stockProfileBaseNames[baseName] = struct{}{}
@@ -136,24 +139,14 @@ func LoadFromDirs(specs []DirSpec) (Catalog, error) {
139 return Catalog{}, errors.New("no Azure Monitor profiles were loaded")
140 }
141
139 - idToBaseName := make(map[string]string, len(seen))
140 - for _, entry := range seen {
141 - id := normalizeKey(entry.Config.ID)
142 - if id == "" {
143 - return Catalog{}, fmt.Errorf("profile %q: decoded empty id", entry.Path)
144 - }
145 - if prevBaseName, ok := idToBaseName[id]; ok {
146 - return Catalog{}, fmt.Errorf("duplicate profile id %q for basenames %q and %q", entry.Config.ID, prevBaseName, entry.BaseName)
147 - }
148 - idToBaseName[id] = entry.BaseName
149 - catalog.byID[id] = entry.Config
150 - catalog.byBaseName[entry.BaseName] = entry.Config
142 + for baseName, entry := range seen {
143 + catalog.byBaseName[baseName] = entry.Config
144 }
145
146 return catalog, nil
147 }
148
156 -func loadProfileFile(path string) (Profile, error) {
149 +func loadProfileFile(path, baseName string) (Profile, error) {
150 data, err := os.ReadFile(path)
151 if err != nil {
152 return Profile{}, err
@@ -166,47 +159,42 @@ func loadProfileFile(path string) (Profile, error) {
159 return Profile{}, fmt.Errorf("unmarshal profile %q: %w", path, err)
160 }
161
169 - profileID := strings.TrimSpace(cfg.ID)
170 - if profileID == "" {
171 - return Profile{}, fmt.Errorf("validate profile %q: missing required field 'id'", path)
162 + if err := cfg.Normalize(baseName); err != nil {
163 + return Profile{}, fmt.Errorf("normalize profile %q: %w", path, err)
164 }
173 - if err := cfg.Validate(fmt.Sprintf("profile %q", profileID)); err != nil {
165 + if err := cfg.Validate(fmt.Sprintf("profile %q", baseName), baseName); err != nil {
166 return Profile{}, fmt.Errorf("validate profile %q: %w", path, err)
167 }
168
169 return cfg, nil
170 }
171
180 -func (c Catalog) Resolve(profileIDs []string) ([]Profile, error) {
181 - if len(profileIDs) == 0 {
172 +func (c Catalog) Resolve(profileNames []string) ([]ResolvedProfile, error) {
173 + if len(profileNames) == 0 {
174 return nil, errors.New("no Azure Monitor profiles selected")
175 }
176
185 - profiles := make([]Profile, 0, len(profileIDs))
186 - for _, id := range profileIDs {
187 - normalizedID := normalizeKey(id)
188 - prof, ok := c.byID[normalizedID]
177 + profiles := make([]ResolvedProfile, 0, len(profileNames))
178 + for _, name := range profileNames {
179 + profileName := strings.TrimSpace(name)
180 + prof, ok := c.byBaseName[profileName]
181 if !ok {
190 - return nil, fmt.Errorf("unknown profile %q", id)
182 + return nil, fmt.Errorf("unknown profile %q", name)
183 }
192 - profiles = append(profiles, prof)
184 + profiles = append(profiles, ResolvedProfile{Name: profileName, Config: prof})
185 }
186 return profiles, nil
187 }
188
189 func (c Catalog) ResolveBaseNames(profileBaseNames []string) ([]Profile, error) {
198 - if len(profileBaseNames) == 0 {
199 - return nil, errors.New("no Azure Monitor profiles selected")
190 + resolved, err := c.Resolve(profileBaseNames)
191 + if err != nil {
192 + return nil, err
193 }
194
202 - profiles := make([]Profile, 0, len(profileBaseNames))
203 - for _, baseName := range profileBaseNames {
204 - normalizedBaseName := normalizeKey(baseName)
205 - prof, ok := c.byBaseName[normalizedBaseName]
206 - if !ok {
207 - return nil, fmt.Errorf("unknown profile basename %q", baseName)
208 - }
209 - profiles = append(profiles, prof)
195 + profiles := make([]Profile, 0, len(resolved))
196 + for _, profile := range resolved {
197 + profiles = append(profiles, profile.Config)
198 }
199 return profiles, nil
200 }
@@ -216,72 +204,30 @@ func (c Catalog) ProfilesForResourceTypes(types map[string]struct{}) []string {
204 return nil
205 }
206
219 - var ids []string
220 - for id, prof := range c.byID {
207 + var names []string
208 + for name, prof := range c.byBaseName {
209 rt := normalizeKey(prof.ResourceType)
210 if rt == "" {
211 continue
212 }
213 if _, ok := types[rt]; ok {
226 - ids = append(ids, id)
214 + names = append(names, name)
215 }
216 }
229 - sort.Strings(ids)
230 - return ids
231 -}
232 -
233 -func (c Catalog) ResourceTypesForProfileIDs(profileIDs []string) ([]string, error) {
234 - if len(profileIDs) == 0 {
235 - return nil, nil
236 - }
237 -
238 - seen := make(map[string]struct{}, len(profileIDs))
239 - types := make([]string, 0, len(profileIDs))
240 - for _, id := range profileIDs {
241 - prof, ok := c.byID[normalizeKey(id)]
242 - if !ok {
243 - return nil, fmt.Errorf("unknown profile %q", id)
244 - }
245 -
246 - rt := strings.TrimSpace(prof.ResourceType)
247 - key := normalizeKey(rt)
248 - if key == "" {
249 - continue
250 - }
251 - if _, ok := seen[key]; ok {
252 - continue
253 - }
254 - seen[key] = struct{}{}
255 - types = append(types, rt)
256 - }
257 -
258 - sort.Strings(types)
259 - return types, nil
260 -}
261 -
262 -func (c Catalog) ProfileIDsForBaseNames(profileBaseNames []string) ([]string, error) {
263 - profiles, err := c.ResolveBaseNames(profileBaseNames)
264 - if err != nil {
265 - return nil, err
266 - }
267 -
268 - ids := make([]string, 0, len(profiles))
269 - for _, profile := range profiles {
270 - ids = append(ids, profile.ID)
271 - }
272 - return ids, nil
217 + sort.Strings(names)
218 + return names
219 }
220
221 func (c Catalog) ResourceTypesForProfileBaseNames(profileBaseNames []string) ([]string, error) {
276 - profiles, err := c.ResolveBaseNames(profileBaseNames)
222 + resolved, err := c.Resolve(profileBaseNames)
223 if err != nil {
224 return nil, err
225 }
226
281 - seen := make(map[string]struct{}, len(profiles))
282 - types := make([]string, 0, len(profiles))
283 - for _, profile := range profiles {
284 - rt := strings.TrimSpace(profile.ResourceType)
227 + seen := make(map[string]struct{}, len(resolved))
228 + types := make([]string, 0, len(resolved))
229 + for _, resolvedProfile := range resolved {
230 + rt := strings.TrimSpace(resolvedProfile.Config.ResourceType)
231 key := normalizeKey(rt)
232 if key == "" {
233 continue
@@ -317,13 +263,13 @@ func profileBaseName(path string) string {
263 }
264
265 func (c Catalog) ResourceTypes() []string {
320 - if len(c.byID) == 0 {
266 + if len(c.byBaseName) == 0 {
267 return nil
268 }
269
324 - seen := make(map[string]struct{}, len(c.byID))
325 - types := make([]string, 0, len(c.byID))
326 - for _, prof := range c.byID {
270 + seen := make(map[string]struct{}, len(c.byBaseName))
271 + types := make([]string, 0, len(c.byBaseName))
272 + for _, prof := range c.byBaseName {
273 rt := strings.TrimSpace(prof.ResourceType)
274 key := normalizeKey(rt)
275 if key == "" {
src/go/plugin/go.d/collector/azure_monitor/azureprofiles/default_catalog_test.go
+1 -5
@@ -37,7 +37,6 @@ func TestLoadFromDefaultDirs_LoadsAllStockProfiles(t *testing.T) {
37 require.NoError(t, err)
38
39 assert.Len(t, catalog.byBaseName, want)
40 - assert.Len(t, catalog.byID, want)
40 }
41
42 func TestDefaultCatalog_CachesSuccessfulLoads(t *testing.T) {
@@ -122,14 +121,11 @@ func stubDefaultCatalog(t *testing.T, cacheEnabled func() bool, loader func() (C
121 }
122
123 func testCatalog(id string) Catalog {
125 - profile := Profile{ID: id, DisplayName: id}
124 + profile := Profile{DisplayName: id}
125 return Catalog{
126 byBaseName: map[string]Profile{
127 normalizeKey(id): profile,
128 },
130 - byID: map[string]Profile{
131 - id: profile,
132 - },
129 stockProfileBaseNames: map[string]struct{}{
130 normalizeKey(id): {},
131 },
src/go/plugin/go.d/collector/azure_monitor/azureprofiles/profile.go
+88 -17
@@ -32,8 +32,7 @@ var SupportedTimeGrains = map[string]time.Duration{
32 }
33
34 type Profile struct {
35 - ID string `yaml:"id" json:"id,omitempty"`
36 - DisplayName string `yaml:"name" json:"name,omitempty"`
35 + DisplayName string `yaml:"display_name" json:"display_name,omitempty"`
36 ResourceType string `yaml:"resource_type" json:"resource_type,omitempty"`
37 MetricNamespace string `yaml:"metric_namespace,omitempty" json:"metric_namespace,omitempty"`
38 Metrics []Metric `yaml:"metrics" json:"metrics,omitempty"`
@@ -52,14 +51,20 @@ type MetricSeries struct {
51 Kind string `yaml:"kind" json:"kind,omitempty"`
52 }
53
55 -func (p Profile) Validate(prefix string) error {
54 +func (p *Profile) Normalize(baseName string) error {
55 + visibleMetrics := visibleMetricsForProfile(baseName, p.Metrics)
56 + normalizeGroupSelectors(baseName, visibleMetrics, &p.Template)
57 + return nil
58 +}
59 +
60 +func (p Profile) Validate(prefix, baseName string) error {
61 var errs []error
62
58 - if !IsValidIdentityID(p.ID) {
59 - errs = append(errs, fmt.Errorf("%s: 'id' must match %q", prefix, reIdentityID.String()))
63 + if !IsValidProfileName(baseName) {
64 + errs = append(errs, fmt.Errorf("%s: profile basename must match %q", prefix, reIdentityID.String()))
65 }
66 if strings.TrimSpace(p.DisplayName) == "" {
62 - errs = append(errs, fmt.Errorf("%s: 'name' is required", prefix))
67 + errs = append(errs, fmt.Errorf("%s: 'display_name' is required", prefix))
68 }
69 if !IsValidResourceType(p.ResourceType) {
70 errs = append(errs, fmt.Errorf("%s: 'resource_type' is invalid", prefix))
@@ -102,7 +107,7 @@ func (p Profile) Validate(prefix string) error {
107 }
108
109 if len(errs) == 0 {
105 - if err := validateTemplate(prefix, p); err != nil {
110 + if err := validateTemplate(prefix, baseName, p); err != nil {
111 errs = append(errs, err)
112 }
113 }
@@ -162,8 +167,8 @@ func (s MetricSeries) validate(metricPrefix string, idx int) error {
167 return errors.Join(errs...)
168 }
169
165 -func ExportedSeriesName(profileID, metricID, aggregation string) string {
166 - return strings.TrimSpace(profileID) + "." + strings.TrimSpace(metricID) + "_" + NormalizeAggregation(aggregation)
170 +func ExportedSeriesName(profileName, metricID, aggregation string) string {
171 + return strings.TrimSpace(profileName) + "." + strings.TrimSpace(metricID) + "_" + NormalizeAggregation(aggregation)
172 }
173
174 func NormalizeAggregation(v string) string {
@@ -198,21 +203,24 @@ func IsValidIdentityID(v string) bool {
203 return reIdentityID.MatchString(strings.TrimSpace(v))
204 }
205
206 +func IsValidProfileName(v string) bool {
207 + return reIdentityID.MatchString(strings.TrimSpace(v))
208 +}
209 +
210 func IsValidResourceType(v string) bool {
211 return reResourceType.MatchString(strings.TrimSpace(v))
212 }
213
205 -func validateTemplate(prefix string, profile Profile) error {
206 - visibleMetrics := make([]string, 0)
207 - for _, metric := range profile.Metrics {
208 - for _, series := range metric.Series {
209 - visibleMetrics = append(visibleMetrics, ExportedSeriesName(profile.ID, metric.ID, series.Aggregation))
210 - }
214 +func validateTemplate(prefix, baseName string, profile Profile) error {
215 + visibleMetrics := visibleMetricsForProfile(baseName, profile.Metrics)
216 + visibleList := make([]string, 0, len(visibleMetrics))
217 + for metric := range visibleMetrics {
218 + visibleList = append(visibleList, metric)
219 }
212 - sort.Strings(visibleMetrics)
220 + sort.Strings(visibleList)
221
222 root := profile.Template
215 - root.Metrics = visibleMetrics
223 + root.Metrics = visibleList
224
225 spec := charttpl.Spec{
226 Version: charttpl.VersionV1,
@@ -225,6 +233,69 @@ func validateTemplate(prefix string, profile Profile) error {
233 return nil
234 }
235
236 +func visibleMetricsForProfile(baseName string, metrics []Metric) map[string]struct{} {
237 + visible := make(map[string]struct{})
238 + for _, metric := range metrics {
239 + for _, series := range metric.Series {
240 + visible[ExportedSeriesName(baseName, metric.ID, series.Aggregation)] = struct{}{}
241 + }
242 + }
243 + return visible
244 +}
245 +
246 +func normalizeGroupSelectors(baseName string, visible map[string]struct{}, group *charttpl.Group) {
247 + if group == nil {
248 + return
249 + }
250 +
251 + for i := range group.Charts {
252 + normalizeChartSelectors(baseName, visible, &group.Charts[i])
253 + }
254 + for i := range group.Groups {
255 + normalizeGroupSelectors(baseName, visible, &group.Groups[i])
256 + }
257 +}
258 +
259 +func normalizeChartSelectors(baseName string, visible map[string]struct{}, chart *charttpl.Chart) {
260 + if chart == nil {
261 + return
262 + }
263 +
264 + for i := range chart.Dimensions {
265 + chart.Dimensions[i].Selector = normalizeSelector(baseName, visible, chart.Dimensions[i].Selector)
266 + }
267 +}
268 +
269 +func normalizeSelector(baseName string, visible map[string]struct{}, selector string) string {
270 + metricName, suffix, ok := splitSelectorMetric(selector)
271 + if !ok || strings.Contains(metricName, ".") {
272 + return selector
273 + }
274 +
275 + candidate := baseName + "." + metricName
276 + if _, ok := visible[candidate]; !ok {
277 + return selector
278 + }
279 + return candidate + suffix
280 +}
281 +
282 +func splitSelectorMetric(selector string) (metricName, suffix string, ok bool) {
283 + selector = strings.TrimSpace(selector)
284 + if selector == "" || strings.HasPrefix(selector, "{") {
285 + return "", "", false
286 + }
287 +
288 + if idx := strings.Index(selector, "{"); idx >= 0 {
289 + metricName = strings.TrimSpace(selector[:idx])
290 + if metricName == "" {
291 + return "", "", false
292 + }
293 + return metricName, selector[idx:], true
294 + }
295 +
296 + return selector, "", true
297 +}
298 +
299 func countChartsInGroup(group charttpl.Group) int {
300 total := len(group.Charts)
301 for _, child := range group.Groups {
src/go/plugin/go.d/collector/azure_monitor/collect.go
+12 -8
@@ -10,16 +10,16 @@ import (
10 )
11
12 func (c *Collector) collect(ctx context.Context) error {
13 - resources, err := c.refreshCollectResources(ctx)
13 + hasResources, err := c.refreshCollectResources(ctx)
14 if err != nil {
15 return err
16 }
17 - if len(resources) == 0 {
17 + if !hasResources {
18 return nil
19 }
20
21 now := c.now()
22 - queryBatches := c.buildQueryBatches(resources, now)
22 + queryBatches := c.buildQueryBatches(now)
23
24 dueInstruments := dueInstrumentsForBatches(queryBatches)
25 samples, err := c.collectQuerySamples(ctx, queryBatches, now)
@@ -33,20 +33,24 @@ func (c *Collector) collect(ctx context.Context) error {
33 return nil
34 }
35
36 -func (c *Collector) refreshCollectResources(ctx context.Context) ([]resourceInfo, error) {
36 +func (c *Collector) refreshCollectResources(ctx context.Context) (bool, error) {
37 if err := c.ensureBootstrapped(ctx); err != nil {
38 - return nil, err
38 + return false, err
39 }
40
41 prevFetchCounter := c.discovery.FetchCounter
42 resources, err := c.refreshDiscovery(ctx, false)
43 if err != nil {
44 - return nil, fmt.Errorf("resource discovery: %w", err)
44 + if c.discovery.FetchedAt.IsZero() {
45 + return false, fmt.Errorf("resource discovery: %w", err)
46 + }
47 + c.Warningf("resource discovery refresh failed, continuing with last known discovery snapshot: %v", err)
48 + resources = c.discovery.Resources
49 }
50 if c.discovery.FetchCounter != prevFetchCounter {
47 - c.observations.pruneStaleResources(resources)
51 + c.observations.pruneStaleResources(c.discovery.ByProfile)
52 }
49 - return resources, nil
53 + return len(resources) > 0, nil
54 }
55
56 func (c *Collector) collectQuerySamples(ctx context.Context, batches []queryBatch, queryNow time.Time) ([]metricSample, error) {
src/go/plugin/go.d/collector/azure_monitor/collector_runtime.go
+9 -1
@@ -17,10 +17,11 @@ type collectorRuntime struct {
17 }
18
19 type profileRuntime struct {
20 - ID string
20 Name string
21 + DisplayName string
22 ResourceType string
23 MetricNamespace string
24 + Filters *ResourceFiltersConfig
25 Metrics []*metricRuntime
26 Template charttpl.Group
27 }
@@ -60,11 +61,17 @@ func (i *instrumentRuntime) observe(labelValues []string, value float64) {
61 type discoveryState struct {
62 Resources []resourceInfo
63 ByType map[string][]resourceInfo
64 + ByProfile map[string][]resourceInfo
65 ExpiresAt time.Time
66 FetchedAt time.Time
67 FetchCounter uint64
68 }
69
70 +type resourceTag struct {
71 + Key string
72 + Value string
73 +}
74 +
75 type resourceInfo struct {
76 SubscriptionID string
77 ID string
@@ -73,6 +80,7 @@ type resourceInfo struct {
80 Type string
81 ResourceGroup string
82 Region string
83 + Tags []resourceTag
84 }
85
86 func (r resourceInfo) String() string {
src/go/plugin/go.d/collector/azure_monitor/collector_test.go
+487 -42
@@ -5,6 +5,7 @@ package azure_monitor
5 import (
6 "context"
7 "encoding/json"
8 + "errors"
9 "os"
10 "path/filepath"
11 "strings"
@@ -74,12 +75,38 @@ func TestConfigSchema_RuntimeContract(t *testing.T) {
75 assert.False(t, hasIDs)
76 _, hasNames := uiProfiles["names"]
77 assert.False(t, hasNames)
78 + _, hasModeAuto := uiProfiles["mode_auto"]
79 + assert.True(t, hasModeAuto)
80 _, hasModeExact := uiProfiles["mode_exact"]
81 assert.True(t, hasModeExact)
82 _, hasModeCombined := uiProfiles["mode_combined"]
83 assert.True(t, hasModeCombined)
84 }
85
86 +func TestConfigSchema_ProfileTagHelpText(t *testing.T) {
87 + raw, err := os.ReadFile("config_schema.json")
88 + require.NoError(t, err)
89 +
90 + var doc map[string]any
91 + require.NoError(t, json.Unmarshal(raw, &doc))
92 +
93 + schema := requireMapField(t, doc, "jsonSchema")
94 + assert.NotContains(t, schema, "allOf")
95 +
96 + uiSchema := requireMapField(t, doc, "uiSchema")
97 + uiProfiles := requireMapField(t, uiSchema, "profiles")
98 + for _, mode := range []string{"mode_auto", "mode_exact", "mode_combined"} {
99 + modeUI := requireMapField(t, uiProfiles, mode)
100 + entries := requireMapField(t, modeUI, "entries")
101 + items := requireMapField(t, entries, "items")
102 + filters := requireMapField(t, items, "filters")
103 + tags := requireMapField(t, filters, "tags")
104 + help, ok := tags["ui:help"].(string)
105 + require.True(t, ok)
106 + assert.Contains(t, help, "Only supported when `discovery.mode` is `filters`")
107 + }
108 +}
109 +
110 func requireMapField(t *testing.T, m map[string]any, key string) map[string]any {
111 t.Helper()
112
@@ -107,6 +134,16 @@ func requireStringSliceField(t *testing.T, m map[string]any, key string) []strin
134 return out
135 }
136
137 +func requireArrayField(t *testing.T, m map[string]any, key string) []any {
138 + t.Helper()
139 +
140 + value, ok := m[key]
141 + require.Truef(t, ok, "missing key %q", key)
142 + items, ok := value.([]any)
143 + require.Truef(t, ok, "key %q is not an array", key)
144 + return items
145 +}
146 +
147 func TestCollector_ConfigurationSerialize(t *testing.T) {
148 collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
149 }
@@ -132,7 +169,7 @@ func TestCollector_Init(t *testing.T) {
169 Timeout: confopt.Duration(-time.Second),
170 Profiles: ProfilesConfig{
171 Mode: profilesModeExact,
135 - ModeExact: &ProfilesModeConfig{Names: []string{"postgres_flexible"}},
172 + ModeExact: testProfilesModeEntries("postgres_flexible"),
173 },
174 Auth: cloudauth.AzureADAuthConfig{
175 Mode: cloudauth.AzureADAuthModeDefault,
@@ -425,6 +462,53 @@ func TestCollector_RefreshDiscoveryDisabledWhenRefreshEveryZero(t *testing.T) {
462 assert.Equal(t, 1, rg.calls())
463 }
464
465 +func TestCollector_RefreshDiscoveryFailureFallsBackToLastKnownSnapshot(t *testing.T) {
466 + now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
467 +
468 + rg := &mockResourceGraph{
469 + resources: []map[string]any{
470 + {
471 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
472 + "name": "pg-a",
473 + "type": "Microsoft.DBforPostgreSQL/flexibleServers",
474 + "resourceGroup": "rg-a",
475 + "location": "eastus",
476 + },
477 + },
478 + }
479 + mx := &mockMetricsClient{
480 + queryResponse: azmetrics.QueryResourcesResponse{MetricResults: azmetrics.MetricResults{Values: []azmetrics.MetricData{
481 + {
482 + ResourceID: ptrString("/subscriptions/sub-1/resourcegroups/rg-a/providers/microsoft.dbforpostgresql/flexibleservers/pg-a"),
483 + Values: []azmetrics.Metric{
484 + metricWithAvg("cpu_percent", now, 21.5),
485 + },
486 + },
487 + }}},
488 + }
489 +
490 + c := newTestCollectorWithMocks(rg, mx)
491 + c.Config = testConfig()
492 + c.Config.Discovery.RefreshEvery = 60
493 + c.now = func() time.Time { return now }
494 +
495 + require.NoError(t, c.Init(context.Background()))
496 + require.NoError(t, c.Check(context.Background()))
497 + assert.Equal(t, 1, rg.calls())
498 + assert.Equal(t, uint64(1), c.discovery.FetchCounter)
499 +
500 + rg.responseErr = errors.New("refresh failed")
501 + now = now.Add(10 * time.Minute)
502 +
503 + series, err := collecttest.CollectScalarSeries(c, metrix.ReadRaw())
504 + require.NoError(t, err)
505 + assert.NotEmpty(t, series)
506 + assert.Contains(t, strings.Join(keysFromSeries(series), "\n"), `subscription_id="sub-1"`)
507 + assert.Equal(t, 2, rg.calls())
508 + assert.GreaterOrEqual(t, mx.calls(), 1)
509 + assert.Equal(t, uint64(1), c.discovery.FetchCounter)
510 +}
511 +
512 func TestCollector_CollectScenarios(t *testing.T) {
513 now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
514
@@ -628,7 +712,7 @@ func TestCollector_CollectScenarios(t *testing.T) {
712 func TestCollector_RefreshDiscovery_PushesModeFiltersIntoQuery(t *testing.T) {
713 tests := map[string]struct {
714 resources []map[string]any
631 - filters *DiscoveryFiltersConfig
715 + filters *ResourceFiltersConfig
716 wantQuery string
717 wantCounts int
718 }{
@@ -649,9 +733,9 @@ func TestCollector_RefreshDiscovery_PushesModeFiltersIntoQuery(t *testing.T) {
733 "location": "eastus",
734 },
735 },
652 - filters: &DiscoveryFiltersConfig{ResourceGroups: []string{"RG-B", " rg-a ", "rg-b"}},
736 + filters: &ResourceFiltersConfig{ResourceGroups: []string{"RG-B", " rg-a ", "rg-b"}},
737 wantCounts: 2,
654 - wantQuery: "resources | where type in~ ('Microsoft.DBforPostgreSQL/flexibleServers') | where resourceGroup in~ ('rg-a', 'rg-b') | project id, name, type, resourceGroup, location",
738 + wantQuery: "resources | where type in~ ('Microsoft.DBforPostgreSQL/flexibleServers') | where resourceGroup in~ ('rg-a', 'rg-b') | project id, name, type, resourceGroup, location, tags",
739 },
740 "resource groups regions and tags": {
741 resources: []map[string]any{
@@ -663,7 +747,7 @@ func TestCollector_RefreshDiscovery_PushesModeFiltersIntoQuery(t *testing.T) {
747 "location": "eastus",
748 },
749 },
666 - filters: &DiscoveryFiltersConfig{
750 + filters: &ResourceFiltersConfig{
751 ResourceGroups: []string{"RG-B", " rg-a ", "rg-b"},
752 Regions: []string{" WestEurope ", "eastus", "EASTUS"},
753 Tags: map[string][]string{
@@ -671,7 +755,27 @@ func TestCollector_RefreshDiscovery_PushesModeFiltersIntoQuery(t *testing.T) {
755 " env ": {"prod"},
756 },
757 },
674 - wantQuery: "resources | where type in~ ('Microsoft.DBforPostgreSQL/flexibleServers') | where resourceGroup in~ ('rg-a', 'rg-b') | where location in~ ('eastus', 'westeurope') | mv-expand bagexpansion=array tags | where isnotempty(tags) | extend tagKey = tostring(tags[0]), tagValue = tostring(tags[1]) | where (tagKey =~ 'env' and tagValue == 'prod') or (tagKey =~ 'role' and tagValue in ('api', 'worker')) | summarize by id, name, type, resourceGroup, location, matchedTagKey = tolower(tagKey) | summarize matchedTagKeys = count() by id, name, type, resourceGroup, location | where matchedTagKeys == 2 | project id, name, type, resourceGroup, location",
758 + wantQuery: "resources | where type in~ ('Microsoft.DBforPostgreSQL/flexibleServers') | where resourceGroup in~ ('rg-a', 'rg-b') | where location in~ ('eastus', 'westeurope') | extend tagsBag = tags | mv-expand bagexpansion=array tags | where isnotempty(tags) | extend tagKey = tostring(tags[0]), tagValue = tostring(tags[1]) | where (tagKey =~ 'env' and tagValue == 'prod') or (tagKey =~ 'role' and tagValue in ('api', 'worker')) | summarize tags = take_any(tagsBag), matchedTagKeys = dcount(tolower(tagKey)) by id, name, type, resourceGroup, location | where matchedTagKeys == 2 | project id, name, type, resourceGroup, location, tags",
759 + },
760 + "deduplicates ids case insensitively": {
761 + resources: []map[string]any{
762 + {
763 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
764 + "name": "pg-a",
765 + "type": "Microsoft.DBforPostgreSQL/flexibleServers",
766 + "resourceGroup": "rg-a",
767 + "location": "eastus",
768 + },
769 + {
770 + "id": "/SUBSCRIPTIONS/sub-1/resourcegroups/rg-a/providers/microsoft.dbforpostgresql/flexibleservers/pg-a",
771 + "name": "pg-a-duplicate",
772 + "type": "Microsoft.DBforPostgreSQL/flexibleServers",
773 + "resourceGroup": "rg-a",
774 + "location": "eastus",
775 + },
776 + },
777 + wantCounts: 1,
778 + wantQuery: "resources | where type in~ ('Microsoft.DBforPostgreSQL/flexibleServers') | project id, name, type, resourceGroup, location, tags",
779 },
780 }
781
@@ -720,12 +824,11 @@ func TestCollector_TimeGrainScheduling(t *testing.T) {
824 }
825
826 cfg := testConfig()
723 - cfg.Profiles.ModeExact = &ProfilesModeConfig{Names: []string{"storage_slow"}}
827 + cfg.Profiles.ModeExact = testProfilesModeEntries("storage_slow")
828
829 catalog := mustLoadStockCatalog(t, map[string]string{
830 "storage_slow.yaml": `
727 -id: storage_slow
728 -name: Azure Storage Slow
831 +display_name: Azure Storage Slow
832 resource_type: Microsoft.Storage/storageAccounts
833 metrics:
834 - id: used_capacity
@@ -798,12 +901,11 @@ func TestCollector_QueryOffsetUsesEffectivePerBatchOffset(t *testing.T) {
901 }
902
903 cfg := testConfig()
801 - cfg.Profiles.ModeExact = &ProfilesModeConfig{Names: []string{"storage_mixed"}}
904 + cfg.Profiles.ModeExact = testProfilesModeEntries("storage_mixed")
905
906 catalog := mustLoadStockCatalog(t, map[string]string{
907 "storage_mixed.yaml": `
805 -id: storage_mixed
806 -name: Azure Storage Mixed Grains
908 +display_name: Azure Storage Mixed Grains
909 resource_type: Microsoft.Storage/storageAccounts
910 metrics:
911 - id: transactions
@@ -908,7 +1010,7 @@ func TestCollector_CheckBootstrapProfileScenarios(t *testing.T) {
1010 },
1011 check: func(t *testing.T, c *Collector, rg *mockResourceGraph) {
1012 require.NotEmpty(t, c.runtime.Profiles)
911 - assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].ID)
1013 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1014 },
1015 },
1016 "combined with explicit profiles": {
@@ -925,12 +1027,12 @@ func TestCollector_CheckBootstrapProfileScenarios(t *testing.T) {
1027 c.Config = testConfig()
1028 c.Config.Profiles.Mode = profilesModeCombined
1029 c.Config.Profiles.ModeExact = nil
928 - c.Config.Profiles.ModeCombined = &ProfilesModeConfig{Names: []string{"cosmos_db"}}
1030 + c.Config.Profiles.ModeCombined = testProfilesModeEntries("cosmos_db")
1031 },
1032 check: func(t *testing.T, c *Collector, rg *mockResourceGraph) {
1033 var ids []string
1034 for _, p := range c.runtime.Profiles {
933 - ids = append(ids, p.ID)
1035 + ids = append(ids, p.Name)
1036 }
1037 assert.Contains(t, ids, "cosmos_db")
1038 assert.Contains(t, ids, "postgres_flexible")
@@ -941,11 +1043,11 @@ func TestCollector_CheckBootstrapProfileScenarios(t *testing.T) {
1043 c.Config = testConfig()
1044 c.Config.Profiles.Mode = profilesModeCombined
1045 c.Config.Profiles.ModeExact = nil
944 - c.Config.Profiles.ModeCombined = &ProfilesModeConfig{Names: []string{"cosmos_db"}}
1046 + c.Config.Profiles.ModeCombined = testProfilesModeEntries("cosmos_db")
1047 },
1048 check: func(t *testing.T, c *Collector, rg *mockResourceGraph) {
1049 require.Len(t, c.runtime.Profiles, 1)
948 - assert.Equal(t, "cosmos_db", c.runtime.Profiles[0].ID)
1050 + assert.Equal(t, "cosmos_db", c.runtime.Profiles[0].Name)
1051 },
1052 },
1053 "default mode resolves to auto and fails with no matches": {
@@ -1019,7 +1121,7 @@ func TestCollector_CheckBootstrapQueryModeScenarios(t *testing.T) {
1121 },
1122 check: func(t *testing.T, c *Collector, rg *mockResourceGraph) {
1123 require.NotEmpty(t, c.runtime.Profiles)
1022 - assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].ID)
1124 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1125 assert.Equal(t, kql, rg.lastQuery())
1126 },
1127 },
@@ -1062,7 +1164,7 @@ func TestCollector_CheckBootstrapQueryModeScenarios(t *testing.T) {
1164 },
1165 check: func(t *testing.T, c *Collector, rg *mockResourceGraph) {
1166 require.Len(t, c.runtime.Profiles, 1)
1065 - assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].ID)
1167 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1168 assert.Empty(t, c.discovery.Resources)
1169 },
1170 },
@@ -1159,6 +1261,258 @@ func TestCollector_InitQueryModeRejectsMalformedRows(t *testing.T) {
1261 }
1262 }
1263
1264 +func TestCollector_ProfileFilters_NarrowMatchedResourcesInFiltersMode(t *testing.T) {
1265 + rg := &mockResourceGraph{
1266 + resources: []map[string]any{
1267 + {
1268 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-a",
1269 + "name": "db-a",
1270 + "type": "Microsoft.Sql/servers/databases",
1271 + "resourceGroup": "rg-a",
1272 + "location": "eastus",
1273 + "tags": map[string]any{
1274 + "env": "prod",
1275 + },
1276 + },
1277 + {
1278 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-b",
1279 + "name": "db-b",
1280 + "type": "Microsoft.Sql/servers/databases",
1281 + "resourceGroup": "rg-a",
1282 + "location": "eastus",
1283 + "tags": map[string]any{
1284 + "env": "dev",
1285 + },
1286 + },
1287 + },
1288 + }
1289 +
1290 + c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1291 + c.Config = testConfig()
1292 + c.Config.Profiles.ModeExact = &ProfilesModeConfig{
1293 + Entries: []ProfileEntryConfig{{
1294 + Name: "sql_database",
1295 + Filters: &ResourceFiltersConfig{
1296 + Tags: map[string][]string{
1297 + "env": {"prod"},
1298 + },
1299 + },
1300 + }},
1301 + }
1302 +
1303 + require.NoError(t, c.Init(context.Background()))
1304 + require.NoError(t, c.Check(context.Background()))
1305 +
1306 + require.Len(t, c.discovery.Resources, 2)
1307 + require.Len(t, c.discovery.ByProfile["sql_database"], 1)
1308 + assert.Equal(t, "db-a", c.discovery.ByProfile["sql_database"][0].Name)
1309 +}
1310 +
1311 +func TestCollector_ProfileFilters_NarrowMatchedResourcesInQueryMode(t *testing.T) {
1312 + const kql = "resources | project id, name, type, resourceGroup, location"
1313 +
1314 + rg := &mockResourceGraph{
1315 + resources: []map[string]any{
1316 + {
1317 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-a",
1318 + "name": "db-a",
1319 + "type": "Microsoft.Sql/servers/databases",
1320 + "resourceGroup": "rg-a",
1321 + "location": "eastus",
1322 + },
1323 + {
1324 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-b",
1325 + "name": "db-b",
1326 + "type": "Microsoft.Sql/servers/databases",
1327 + "resourceGroup": "rg-a",
1328 + "location": "westeurope",
1329 + },
1330 + },
1331 + }
1332 +
1333 + c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1334 + c.Config = testConfig()
1335 + c.Config.Discovery.Mode = discoveryModeQuery
1336 + c.Config.Discovery.ModeFilters = nil
1337 + c.Config.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: kql}
1338 + c.Config.Profiles.ModeExact = &ProfilesModeConfig{
1339 + Entries: []ProfileEntryConfig{{
1340 + Name: "sql_database",
1341 + Filters: &ResourceFiltersConfig{
1342 + Regions: []string{"eastus"},
1343 + },
1344 + }},
1345 + }
1346 +
1347 + require.NoError(t, c.Init(context.Background()))
1348 + require.NoError(t, c.Check(context.Background()))
1349 +
1350 + require.Len(t, c.discovery.Resources, 2)
1351 + require.Len(t, c.discovery.ByProfile["sql_database"], 1)
1352 + assert.Equal(t, "db-a", c.discovery.ByProfile["sql_database"][0].Name)
1353 +}
1354 +
1355 +func TestCollector_CombinedExplicitEntryOverlaysAutoProfile(t *testing.T) {
1356 + rg := &mockResourceGraph{
1357 + resources: []map[string]any{
1358 + {
1359 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1360 + "name": "pg-a",
1361 + "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1362 + "resourceGroup": "rg-a",
1363 + "location": "eastus",
1364 + },
1365 + },
1366 + }
1367 +
1368 + c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1369 + c.Config = testConfig()
1370 + c.Config.Profiles.Mode = profilesModeCombined
1371 + c.Config.Profiles.ModeExact = nil
1372 + c.Config.Profiles.ModeCombined = &ProfilesModeConfig{
1373 + Entries: []ProfileEntryConfig{{
1374 + Name: "postgres_flexible",
1375 + Filters: &ResourceFiltersConfig{
1376 + Regions: []string{"westeurope"},
1377 + },
1378 + }},
1379 + }
1380 +
1381 + require.NoError(t, c.Init(context.Background()))
1382 + require.NoError(t, c.Check(context.Background()))
1383 +
1384 + require.Len(t, c.runtime.Profiles, 1)
1385 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1386 + require.NotNil(t, c.runtime.Profiles[0].Filters)
1387 + assert.Equal(t, []string{"westeurope"}, c.runtime.Profiles[0].Filters.Regions)
1388 + require.Len(t, c.discovery.Resources, 1)
1389 + assert.NotContains(t, c.discovery.ByProfile, "postgres_flexible")
1390 +}
1391 +
1392 +func TestCollector_AutoModeEntriesStayDormantAcrossRefresh(t *testing.T) {
1393 + rg := &mockResourceGraph{
1394 + resources: []map[string]any{
1395 + {
1396 + "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1397 + "name": "pg-a",
1398 + "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1399 + "resourceGroup": "rg-a",
1400 + "location": "eastus",
1401 + },
1402 + },
1403 + }
1404 +
1405 + c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1406 + c.Config = testConfig()
1407 + c.Config.Profiles.Mode = profilesModeAuto
1408 + c.Config.Profiles.ModeExact = nil
1409 + c.Config.Profiles.ModeCombined = nil
1410 + c.Config.Profiles.ModeAuto = &ProfilesModeConfig{
1411 + Entries: []ProfileEntryConfig{{
1412 + Name: "sql_database",
1413 + Filters: &ResourceFiltersConfig{
1414 + ResourceGroups: []string{"prod-rg"},
1415 + },
1416 + }},
1417 + }
1418 +
1419 + require.NoError(t, c.Init(context.Background()))
1420 + require.NoError(t, c.Check(context.Background()))
1421 +
1422 + require.Len(t, c.runtime.Profiles, 1)
1423 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1424 + assert.NotContains(t, c.discovery.ByProfile, "sql_database")
1425 +
1426 + rg.setResources([]map[string]any{
1427 + {
1428 + "id": "/subscriptions/sub-1/resourceGroups/prod-rg/providers/Microsoft.Sql/servers/sql-a/databases/db-a",
1429 + "name": "db-a",
1430 + "type": "Microsoft.Sql/servers/databases",
1431 + "resourceGroup": "prod-rg",
1432 + "location": "eastus",
1433 + },
1434 + })
1435 +
1436 + _, err := c.refreshDiscovery(context.Background(), true)
1437 + require.NoError(t, err)
1438 +
1439 + require.Len(t, c.runtime.Profiles, 1)
1440 + assert.Equal(t, "postgres_flexible", c.runtime.Profiles[0].Name)
1441 + assert.Empty(t, c.discovery.Resources)
1442 + assert.NotContains(t, c.discovery.ByProfile, "sql_database")
1443 + assert.Contains(t, rg.lastQuery(), "Microsoft.DBforPostgreSQL/flexibleServers")
1444 + assert.NotContains(t, rg.lastQuery(), "Microsoft.Sql/servers/databases")
1445 +}
1446 +
1447 +func TestObservationState_PruneStaleResources_RemovesOldProfileMembership(t *testing.T) {
1448 + resource := resourceInfo{
1449 + SubscriptionID: "sub-1",
1450 + UID: "uid-a",
1451 + Name: "db-a",
1452 + ResourceGroup: "rg-a",
1453 + Region: "eastus",
1454 + Type: "Microsoft.Sql/servers/databases",
1455 + }
1456 + labels := labelValues(resourceLabels(resource, "sql_database"))
1457 + key := sampleObservationKey("sql.cpu", labels)
1458 +
1459 + state := &observationState{
1460 + accumulators: map[string]float64{
1461 + key: 10,
1462 + },
1463 + lastObserved: map[string]lastObservation{
1464 + key: {
1465 + instrument: "sql.cpu",
1466 + labelValues: append([]string(nil), labels...),
1467 + value: 10,
1468 + },
1469 + },
1470 + }
1471 +
1472 + state.pruneStaleResources(map[string][]resourceInfo{
1473 + "postgres_flexible": {resource},
1474 + })
1475 +
1476 + assert.Empty(t, state.lastObserved)
1477 + assert.Empty(t, state.accumulators)
1478 +}
1479 +
1480 +func TestObservationState_PruneStaleResources_RemovesLabelChurnForSameResource(t *testing.T) {
1481 + oldResource := resourceInfo{
1482 + SubscriptionID: "sub-1",
1483 + UID: "uid-a",
1484 + Name: "db-a",
1485 + ResourceGroup: "rg-a",
1486 + Region: "eastus",
1487 + Type: "Microsoft.Sql/servers/databases",
1488 + }
1489 + newResource := oldResource
1490 + newResource.Name = "db-a-renamed"
1491 +
1492 + labels := labelValues(resourceLabels(oldResource, "sql_database"))
1493 + key := sampleObservationKey("sql.cpu", labels)
1494 +
1495 + state := &observationState{
1496 + accumulators: map[string]float64{
1497 + key: 10,
1498 + },
1499 + lastObserved: map[string]lastObservation{
1500 + key: {
1501 + instrument: "sql.cpu",
1502 + labelValues: append([]string(nil), labels...),
1503 + value: 10,
1504 + },
1505 + },
1506 + }
1507 +
1508 + state.pruneStaleResources(map[string][]resourceInfo{
1509 + "sql_database": {newResource},
1510 + })
1511 +
1512 + assert.Empty(t, state.lastObserved)
1513 + assert.Empty(t, state.accumulators)
1514 +}
1515 +
1516 func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1517 tests := map[string]struct {
1518 cfg Config
@@ -1179,7 +1533,7 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1533 cfg := testConfig()
1534 cfg.Discovery.Mode = discoveryModeQuery
1535 cfg.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: "resources | project id, name, type, resourceGroup, location"}
1182 - cfg.Discovery.ModeFilters = &DiscoveryFiltersConfig{ResourceGroups: []string{"rg-a"}}
1536 + cfg.Discovery.ModeFilters = &ResourceFiltersConfig{ResourceGroups: []string{"rg-a"}}
1537 return cfg
1538 }(),
1539 wantErr: false,
@@ -1199,7 +1553,7 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1553 cfg: func() Config {
1554 cfg := testConfig()
1555 cfg.Discovery.Mode = discoveryModeFilters
1202 - cfg.Discovery.ModeFilters = &DiscoveryFiltersConfig{ResourceGroups: []string{""}}
1556 + cfg.Discovery.ModeFilters = &ResourceFiltersConfig{ResourceGroups: []string{""}}
1557 return cfg
1558 }(),
1559 wantErr: true,
@@ -1209,7 +1563,7 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1563 cfg: func() Config {
1564 cfg := testConfig()
1565 cfg.Discovery.Mode = discoveryModeFilters
1212 - cfg.Discovery.ModeFilters = &DiscoveryFiltersConfig{Regions: []string{" "}}
1566 + cfg.Discovery.ModeFilters = &ResourceFiltersConfig{Regions: []string{" "}}
1567 return cfg
1568 }(),
1569 wantErr: true,
@@ -1219,7 +1573,7 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1573 cfg: func() Config {
1574 cfg := testConfig()
1575 cfg.Profiles.Mode = profilesModeAuto
1222 - cfg.Profiles.ModeCombined = &ProfilesModeConfig{Names: []string{"cosmos_db"}}
1576 + cfg.Profiles.ModeCombined = testProfilesModeEntries("cosmos_db")
1577 return cfg
1578 }(),
1579 wantErr: false,
@@ -1228,20 +1582,48 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1582 cfg: func() Config {
1583 cfg := testConfig()
1584 cfg.Profiles.Mode = profilesModeExact
1231 - cfg.Profiles.ModeCombined = &ProfilesModeConfig{Names: []string{"cosmos_db"}}
1585 + cfg.Profiles.ModeCombined = testProfilesModeEntries("cosmos_db")
1586 return cfg
1587 }(),
1588 wantErr: false,
1589 },
1236 - "exact mode rejects duplicate names ignoring case": {
1590 + "exact mode rejects duplicate entry names": {
1591 + cfg: func() Config {
1592 + cfg := testConfig()
1593 + cfg.Profiles.Mode = profilesModeExact
1594 + cfg.Profiles.ModeExact = &ProfilesModeConfig{Entries: []ProfileEntryConfig{{Name: "postgres_flexible"}, {Name: "postgres_flexible"}}}
1595 + return cfg
1596 + }(),
1597 + wantErr: true,
1598 + wantErrContain: "'profiles.mode_exact.entries' contains duplicate entry name 'postgres_flexible'",
1599 + },
1600 + "exact mode rejects non-lowercase entry names": {
1601 cfg: func() Config {
1602 cfg := testConfig()
1603 cfg.Profiles.Mode = profilesModeExact
1240 - cfg.Profiles.ModeExact = &ProfilesModeConfig{Names: []string{"POSTGRES_FLEXIBLE", "postgres_flexible"}}
1604 + cfg.Profiles.ModeExact = &ProfilesModeConfig{Entries: []ProfileEntryConfig{{Name: "POSTGRES_FLEXIBLE"}}}
1605 return cfg
1606 }(),
1607 wantErr: true,
1244 - wantErrContain: "'profiles.mode_exact.names' contains duplicate value 'postgres_flexible'",
1608 + wantErrContain: "'profiles.mode_exact.entries[0].name' must match",
1609 + },
1610 + "query mode ignores profile tag filters during validation": {
1611 + cfg: func() Config {
1612 + cfg := testConfig()
1613 + cfg.Discovery.Mode = discoveryModeQuery
1614 + cfg.Discovery.ModeFilters = nil
1615 + cfg.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: "resources | project id, name, type, resourceGroup, location"}
1616 + cfg.Profiles.ModeExact = &ProfilesModeConfig{
1617 + Entries: []ProfileEntryConfig{{
1618 + Name: "postgres_flexible",
1619 + Filters: &ResourceFiltersConfig{
1620 + Tags: map[string][]string{"env": {"prod"}},
1621 + },
1622 + }},
1623 + }
1624 + return cfg
1625 + }(),
1626 + wantErr: false,
1627 },
1628 }
1629
@@ -1258,6 +1640,53 @@ func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
1640 }
1641 }
1642
1643 +func TestSanitizeIgnoredProfileTagFilters(t *testing.T) {
1644 + cfg := testConfig()
1645 + cfg.Discovery.Mode = discoveryModeQuery
1646 + cfg.Discovery.ModeFilters = nil
1647 + cfg.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: "resources | project id, name, type, resourceGroup, location"}
1648 + cfg.Profiles.Mode = profilesModeExact
1649 + cfg.Profiles.ModeAuto = nil
1650 + cfg.Profiles.ModeCombined = nil
1651 + cfg.Profiles.ModeExact = &ProfilesModeConfig{
1652 + Entries: []ProfileEntryConfig{
1653 + {
1654 + Name: "postgres_flexible",
1655 + Filters: &ResourceFiltersConfig{
1656 + ResourceGroups: []string{"rg-a"},
1657 + Tags: map[string][]string{"env": {"prod"}},
1658 + },
1659 + },
1660 + {
1661 + Name: "sql_database",
1662 + Filters: &ResourceFiltersConfig{
1663 + Tags: map[string][]string{"tier": {"critical"}},
1664 + },
1665 + },
1666 + },
1667 + }
1668 +
1669 + sanitized, warnings := sanitizeIgnoredProfileTagFilters(cfg)
1670 +
1671 + assert.Equal(t, []string{
1672 + "profiles.mode_exact.entries[0].filters.tags",
1673 + "profiles.mode_exact.entries[1].filters.tags",
1674 + }, warnings)
1675 + require.NotNil(t, sanitized.Profiles.ModeExact)
1676 + require.Len(t, sanitized.Profiles.ModeExact.Entries, 2)
1677 + require.NotNil(t, sanitized.Profiles.ModeExact.Entries[0].Filters)
1678 + assert.Equal(t, []string{"rg-a"}, sanitized.Profiles.ModeExact.Entries[0].Filters.ResourceGroups)
1679 + assert.Nil(t, sanitized.Profiles.ModeExact.Entries[0].Filters.Tags)
1680 + assert.Nil(t, sanitized.Profiles.ModeExact.Entries[1].Filters)
1681 +
1682 + require.NotNil(t, cfg.Profiles.ModeExact)
1683 + require.Len(t, cfg.Profiles.ModeExact.Entries, 2)
1684 + require.NotNil(t, cfg.Profiles.ModeExact.Entries[0].Filters)
1685 + assert.Equal(t, map[string][]string{"env": {"prod"}}, cfg.Profiles.ModeExact.Entries[0].Filters.Tags)
1686 + require.NotNil(t, cfg.Profiles.ModeExact.Entries[1].Filters)
1687 + assert.Equal(t, map[string][]string{"tier": {"critical"}}, cfg.Profiles.ModeExact.Entries[1].Filters.Tags)
1688 +}
1689 +
1690 func TestMergeProfileIDs(t *testing.T) {
1691 tests := map[string]struct {
1692 explicit []string
@@ -1283,19 +1712,18 @@ func TestMergeProfileIDs(t *testing.T) {
1712
1713 for name, tc := range tests {
1714 t.Run(name, func(t *testing.T) {
1286 - got := mergeProfileIDs(tc.explicit, tc.discovered)
1715 + got := mergeProfileNames(tc.explicit, tc.discovered)
1716 assert.Equal(t, tc.want, got)
1717 })
1718 }
1719 }
1720
1721 func TestBuildCollectorRuntime_DetectsChartIDCollision(t *testing.T) {
1293 - profileIDs := []string{"redis_upper", "redis_lower"}
1722 + profileNames := []string{"redis_upper", "redis_lower"}
1723
1724 catalog := mustLoadStockCatalog(t, map[string]string{
1725 "redis_upper.yaml": `
1297 -id: redis_upper
1298 -name: Azure Redis Cache Upper
1726 +display_name: Azure Redis Cache Upper
1727 resource_type: Microsoft.Cache/Redis
1728 metrics:
1729 - id: connectedclients
@@ -1320,8 +1748,7 @@ template:
1748 name: average
1749 `,
1750 "redis_lower.yaml": `
1323 -id: redis_lower
1324 -name: Azure Redis Cache Lower
1751 +display_name: Azure Redis Cache Lower
1752 resource_type: Microsoft.Cache/Redis
1753 metrics:
1754 - id: cachehits
@@ -1347,7 +1774,7 @@ template:
1774 `,
1775 })
1776
1350 - _, err := buildCollectorRuntimeFromConfig(profileIDs, catalog)
1777 + _, err := buildCollectorRuntimeFromConfig(profileNames, nil, catalog)
1778 require.Error(t, err)
1779 }
1780
@@ -1363,7 +1790,7 @@ func testConfig() Config {
1790 },
1791 Profiles: ProfilesConfig{
1792 Mode: profilesModeExact,
1366 - ModeExact: &ProfilesModeConfig{Names: []string{"postgres_flexible"}},
1793 + ModeExact: testProfilesModeEntries("postgres_flexible"),
1794 },
1795 QueryOffset: 180,
1796 Timeout: defaultTimeout,
@@ -1393,6 +1820,14 @@ func mustLoadStockCatalog(t *testing.T, files map[string]string) azureprofiles.C
1820 return catalog
1821 }
1822
1823 +func testProfilesModeEntries(names ...string) *ProfilesModeConfig {
1824 + entries := make([]ProfileEntryConfig, 0, len(names))
1825 + for _, name := range names {
1826 + entries = append(entries, ProfileEntryConfig{Name: name})
1827 + }
1828 + return &ProfilesModeConfig{Entries: entries}
1829 +}
1830 +
1831 func newTestCollectorWithMocks(rg *mockResourceGraph, mx *mockMetricsClient) *Collector {
1832 c := New()
1833 c.newResourceGraph = func(string, azcore.TokenCredential, azcloud.Configuration) (resourceGraphClient, error) {
@@ -1405,13 +1840,14 @@ func newTestCollectorWithMocks(rg *mockResourceGraph, mx *mockMetricsClient) *Co
1840 }
1841
1842 type mockResourceGraph struct {
1408 - mu sync.Mutex
1409 - resources []map[string]any
1410 - count int
1411 - query string
1412 - subs []string
1413 - timeout time.Duration
1414 - hasDL bool
1843 + mu sync.Mutex
1844 + resources []map[string]any
1845 + responseErr error
1846 + count int
1847 + query string
1848 + subs []string
1849 + timeout time.Duration
1850 + hasDL bool
1851 }
1852
1853 func (m *mockResourceGraph) Resources(ctx context.Context, req armresourcegraph.QueryRequest, _ *armresourcegraph.ClientResourcesOptions) (armresourcegraph.ClientResourcesResponse, error) {
@@ -1438,6 +1874,9 @@ func (m *mockResourceGraph) Resources(ctx context.Context, req armresourcegraph.
1874 } else {
1875 m.timeout = 0
1876 }
1877 + if m.responseErr != nil {
1878 + return armresourcegraph.ClientResourcesResponse{}, m.responseErr
1879 + }
1880
1881 rows := make([]any, 0, len(m.resources))
1882 for _, r := range m.resources {
@@ -1470,6 +1909,12 @@ func (m *mockResourceGraph) lastSubscriptions() []string {
1909 return append([]string(nil), m.subs...)
1910 }
1911
1912 +func (m *mockResourceGraph) setResources(resources []map[string]any) {
1913 + m.mu.Lock()
1914 + defer m.mu.Unlock()
1915 + m.resources = append([]map[string]any(nil), resources...)
1916 +}
1917 +
1918 type mockMetricsClient struct {
1919 mu sync.Mutex
1920 queryResponse azmetrics.QueryResourcesResponse
src/go/plugin/go.d/collector/azure_monitor/config.go
+173 -49
@@ -58,13 +58,13 @@ type Config struct {
58 }
59
60 type DiscoveryConfig struct {
61 - RefreshEvery int `yaml:"refresh_every,omitempty" json:"refresh_every"`
62 - Mode string `yaml:"mode,omitempty" json:"mode"`
63 - ModeFilters *DiscoveryFiltersConfig `yaml:"mode_filters,omitempty" json:"mode_filters,omitempty"`
64 - ModeQuery *DiscoveryQueryConfig `yaml:"mode_query,omitempty" json:"mode_query,omitempty"`
61 + RefreshEvery int `yaml:"refresh_every,omitempty" json:"refresh_every"`
62 + Mode string `yaml:"mode,omitempty" json:"mode"`
63 + ModeFilters *ResourceFiltersConfig `yaml:"mode_filters,omitempty" json:"mode_filters,omitempty"`
64 + ModeQuery *DiscoveryQueryConfig `yaml:"mode_query,omitempty" json:"mode_query,omitempty"`
65 }
66
67 -type DiscoveryFiltersConfig struct {
67 +type ResourceFiltersConfig struct {
68 ResourceGroups []string `yaml:"resource_groups,omitempty" json:"resource_groups,omitempty"`
69 Regions []string `yaml:"regions,omitempty" json:"regions,omitempty"`
70 Tags map[string][]string `yaml:"tags,omitempty" json:"tags,omitempty"`
@@ -74,12 +74,18 @@ type DiscoveryQueryConfig struct {
74 KQL string `yaml:"kql" json:"kql"`
75 }
76
77 +type ProfileEntryConfig struct {
78 + Name string `yaml:"name" json:"name"`
79 + Filters *ResourceFiltersConfig `yaml:"filters,omitempty" json:"filters,omitempty"`
80 +}
81 +
82 type ProfilesModeConfig struct {
78 - Names []string `yaml:"names,omitempty" json:"names,omitempty"`
83 + Entries []ProfileEntryConfig `yaml:"entries,omitempty" json:"entries,omitempty"`
84 }
85
86 type ProfilesConfig struct {
87 Mode string `yaml:"mode,omitempty" json:"mode"`
88 + ModeAuto *ProfilesModeConfig `yaml:"mode_auto,omitempty" json:"mode_auto,omitempty"`
89 ModeExact *ProfilesModeConfig `yaml:"mode_exact,omitempty" json:"mode_exact,omitempty"`
90 ModeCombined *ProfilesModeConfig `yaml:"mode_combined,omitempty" json:"mode_combined,omitempty"`
91 }
@@ -160,7 +166,7 @@ func (c Config) validate() error {
166 errs = append(errs, errors.New("'limits.max_metrics_per_query' must be between 1 and 20"))
167 }
168
163 - switch strings.ToLower(strings.TrimSpace(c.Cloud)) {
169 + switch stringsLowerTrim(c.Cloud) {
170 case cloudPublic, cloudGovernment, cloudChina:
171 default:
172 errs = append(errs, fmt.Errorf("'cloud' must be one of: %s, %s, %s", cloudPublic, cloudGovernment, cloudChina))
@@ -170,9 +176,11 @@ func (c Config) validate() error {
176 errs = append(errs, err)
177 }
178
173 - switch strings.ToLower(strings.TrimSpace(c.Discovery.Mode)) {
179 + validateProfileTags := false
180 + switch stringsLowerTrim(c.Discovery.Mode) {
181 case discoveryModeFilters:
175 - errs = append(errs, validateDiscoveryFilters(c.Discovery.ModeFilters)...)
182 + validateProfileTags = true
183 + errs = append(errs, validateResourceFilters("discovery.mode_filters", c.Discovery.ModeFilters, true)...)
184 case discoveryModeQuery:
185 if c.Discovery.ModeQuery == nil || strings.TrimSpace(c.Discovery.ModeQuery.KQL) == "" {
186 errs = append(errs, errors.New("'discovery.mode_query.kql' must not be empty when discovery.mode is 'query'"))
@@ -181,19 +189,22 @@ func (c Config) validate() error {
189 errs = append(errs, fmt.Errorf("'discovery.mode' must be one of: %s, %s", discoveryModeFilters, discoveryModeQuery))
190 }
191
184 - switch strings.ToLower(strings.TrimSpace(c.Profiles.Mode)) {
192 + switch stringsLowerTrim(c.Profiles.Mode) {
193 case profilesModeAuto:
194 + errs = append(errs, validateProfileEntries("profiles.mode_auto.entries", modeEntries(c.Profiles.ModeAuto), validateProfileTags)...)
195 case profilesModeExact:
187 - if c.Profiles.ModeExact == nil || len(c.Profiles.ModeExact.Names) == 0 {
188 - errs = append(errs, fmt.Errorf("'profiles.mode_exact.names' must not be empty when profiles.mode is '%s'", c.Profiles.Mode))
196 + entries := modeEntries(c.Profiles.ModeExact)
197 + if len(entries) == 0 {
198 + errs = append(errs, fmt.Errorf("'profiles.mode_exact.entries' must not be empty when profiles.mode is '%s'", c.Profiles.Mode))
199 } else {
190 - errs = append(errs, validateProfilesList("profiles.mode_exact.names", c.Profiles.ModeExact.Names)...)
200 + errs = append(errs, validateProfileEntries("profiles.mode_exact.entries", entries, validateProfileTags)...)
201 }
202 case profilesModeCombined:
193 - if c.Profiles.ModeCombined == nil || len(c.Profiles.ModeCombined.Names) == 0 {
194 - errs = append(errs, fmt.Errorf("'profiles.mode_combined.names' must not be empty when profiles.mode is '%s'", c.Profiles.Mode))
203 + entries := modeEntries(c.Profiles.ModeCombined)
204 + if len(entries) == 0 {
205 + errs = append(errs, fmt.Errorf("'profiles.mode_combined.entries' must not be empty when profiles.mode is '%s'", c.Profiles.Mode))
206 } else {
196 - errs = append(errs, validateProfilesList("profiles.mode_combined.names", c.Profiles.ModeCombined.Names)...)
207 + errs = append(errs, validateProfileEntries("profiles.mode_combined.entries", entries, validateProfileTags)...)
208 }
209 default:
210 errs = append(errs, fmt.Errorf("'profiles.mode' must be one of: %s, %s, %s",
@@ -203,75 +214,188 @@ func (c Config) validate() error {
214 return errors.Join(errs...)
215 }
216
206 -func validateDiscoveryFilters(filters *DiscoveryFiltersConfig) []error {
217 +func validateProfileEntries(path string, entries []ProfileEntryConfig, validateTags bool) []error {
218 + if len(entries) == 0 {
219 + return nil
220 + }
221 +
222 + var errs []error
223 + seen := map[string]struct{}{}
224 + for i, entry := range entries {
225 + entryPath := fmt.Sprintf("%s[%d]", path, i)
226 + name := stringsTrim(entry.Name)
227 + if !isValidProfileName(name) {
228 + errs = append(errs, fmt.Errorf("'%s.name' must match %q", entryPath, profileNamePattern))
229 + } else {
230 + if _, ok := seen[name]; ok {
231 + errs = append(errs, fmt.Errorf("'%s' contains duplicate entry name '%s'", path, name))
232 + }
233 + seen[name] = struct{}{}
234 + }
235 + errs = append(errs, validateResourceFilters(entryPath+".filters", entry.Filters, validateTags)...)
236 + }
237 + return errs
238 +}
239 +
240 +func validateResourceFilters(path string, filters *ResourceFiltersConfig, validateTags bool) []error {
241 if filters == nil {
242 return nil
243 }
244
245 var errs []error
246 for i, v := range filters.ResourceGroups {
213 - if strings.TrimSpace(v) == "" {
214 - errs = append(errs, fmt.Errorf("'discovery.mode_filters.resource_groups[%d]' must not be empty", i))
247 + if stringsTrim(v) == "" {
248 + errs = append(errs, fmt.Errorf("'%s.resource_groups[%d]' must not be empty", path, i))
249 }
250 }
251 for i, v := range filters.Regions {
218 - if strings.TrimSpace(v) == "" {
219 - errs = append(errs, fmt.Errorf("'discovery.mode_filters.regions[%d]' must not be empty", i))
252 + if stringsTrim(v) == "" {
253 + errs = append(errs, fmt.Errorf("'%s.regions[%d]' must not be empty", path, i))
254 }
255 }
256 + if !validateTags && len(filters.Tags) > 0 {
257 + return errs
258 + }
259 for key, values := range filters.Tags {
223 - if strings.TrimSpace(key) == "" {
224 - errs = append(errs, errors.New("'discovery.mode_filters.tags' contains an empty key"))
260 + if stringsTrim(key) == "" {
261 + errs = append(errs, fmt.Errorf("'%s.tags' contains an empty key", path))
262 continue
263 }
264 if len(values) == 0 {
228 - errs = append(errs, fmt.Errorf("'discovery.mode_filters.tags.%s' must contain at least one value", key))
265 + errs = append(errs, fmt.Errorf("'%s.tags.%s' must contain at least one value", path, key))
266 continue
267 }
268 for i, v := range values {
232 - if strings.TrimSpace(v) == "" {
233 - errs = append(errs, fmt.Errorf("'discovery.mode_filters.tags.%s[%d]' must not be empty", key, i))
269 + if stringsTrim(v) == "" {
270 + errs = append(errs, fmt.Errorf("'%s.tags.%s[%d]' must not be empty", path, key, i))
271 }
272 }
273 }
274 return errs
275 }
276
240 -func validateProfilesList(path string, profiles []string) []error {
241 - var errs []error
242 - seen := map[string]struct{}{}
243 - for _, name := range profiles {
244 - n := strings.TrimSpace(name)
245 - if n == "" {
246 - errs = append(errs, fmt.Errorf("'%s' contains an empty value", path))
277 +func sanitizeIgnoredProfileTagFilters(cfg Config) (Config, []string) {
278 + if stringsLowerTrim(cfg.Discovery.Mode) != discoveryModeQuery {
279 + return cfg, nil
280 + }
281 +
282 + switch stringsLowerTrim(cfg.Profiles.Mode) {
283 + case profilesModeAuto:
284 + cfg.Profiles.ModeAuto = cloneProfilesModeConfig(cfg.Profiles.ModeAuto)
285 + warnings := stripIgnoredProfileTagFilters("profiles.mode_auto.entries", cfg.Profiles.ModeAuto)
286 + return cfg, warnings
287 + case profilesModeExact:
288 + cfg.Profiles.ModeExact = cloneProfilesModeConfig(cfg.Profiles.ModeExact)
289 + warnings := stripIgnoredProfileTagFilters("profiles.mode_exact.entries", cfg.Profiles.ModeExact)
290 + return cfg, warnings
291 + case profilesModeCombined:
292 + cfg.Profiles.ModeCombined = cloneProfilesModeConfig(cfg.Profiles.ModeCombined)
293 + warnings := stripIgnoredProfileTagFilters("profiles.mode_combined.entries", cfg.Profiles.ModeCombined)
294 + return cfg, warnings
295 + default:
296 + return cfg, nil
297 + }
298 +}
299 +
300 +func cloneProfilesModeConfig(src *ProfilesModeConfig) *ProfilesModeConfig {
301 + if src == nil {
302 + return nil
303 + }
304 +
305 + out := &ProfilesModeConfig{
306 + Entries: make([]ProfileEntryConfig, len(src.Entries)),
307 + }
308 + for i, entry := range src.Entries {
309 + out.Entries[i] = ProfileEntryConfig{
310 + Name: entry.Name,
311 + Filters: cloneResourceFilters(entry.Filters),
312 + }
313 + }
314 + return out
315 +}
316 +
317 +func stripIgnoredProfileTagFilters(path string, cfg *ProfilesModeConfig) []string {
318 + if cfg == nil {
319 + return nil
320 + }
321 +
322 + var warnings []string
323 + for i := range cfg.Entries {
324 + filters := cfg.Entries[i].Filters
325 + if filters == nil || len(filters.Tags) == 0 {
326 continue
327 }
249 - norm := stringsLowerTrim(n)
250 - if _, ok := seen[norm]; ok {
251 - errs = append(errs, fmt.Errorf("'%s' contains duplicate value '%s'", path, n))
328 +
329 + warnings = append(warnings, fmt.Sprintf("%s[%d].filters.tags", path, i))
330 + filters.Tags = nil
331 + if len(filters.ResourceGroups) == 0 && len(filters.Regions) == 0 {
332 + cfg.Entries[i].Filters = nil
333 }
253 - seen[norm] = struct{}{}
334 }
255 - return errs
335 + return warnings
336 }
337
258 -func (p ProfilesConfig) explicitBaseNames() []string {
259 - switch stringsLowerTrim(p.Mode) {
260 - case profilesModeExact:
261 - if p.ModeExact != nil {
262 - return p.ModeExact.Names
338 +func modeEntries(cfg *ProfilesModeConfig) []ProfileEntryConfig {
339 + if cfg == nil {
340 + return nil
341 + }
342 + return cfg.Entries
343 +}
344 +
345 +func entryNames(entries []ProfileEntryConfig) []string {
346 + if len(entries) == 0 {
347 + return nil
348 + }
349 +
350 + names := make([]string, 0, len(entries))
351 + for _, entry := range entries {
352 + if name := stringsTrim(entry.Name); name != "" {
353 + names = append(names, name)
354 }
264 - case profilesModeCombined:
265 - if p.ModeCombined != nil {
266 - return p.ModeCombined.Names
355 + }
356 + return names
357 +}
358 +
359 +func entryMap(entries []ProfileEntryConfig) map[string]ProfileEntryConfig {
360 + if len(entries) == 0 {
361 + return nil
362 + }
363 +
364 + out := make(map[string]ProfileEntryConfig, len(entries))
365 + for _, entry := range entries {
366 + name := stringsTrim(entry.Name)
367 + if name == "" {
368 + continue
369 + }
370 + out[name] = ProfileEntryConfig{
371 + Name: name,
372 + Filters: cloneResourceFilters(entry.Filters),
373 + }
374 + }
375 + return out
376 +}
377 +
378 +func cloneResourceFilters(src *ResourceFiltersConfig) *ResourceFiltersConfig {
379 + if src == nil {
380 + return nil
381 + }
382 +
383 + dst := &ResourceFiltersConfig{
384 + ResourceGroups: append([]string(nil), src.ResourceGroups...),
385 + Regions: append([]string(nil), src.Regions...),
386 + }
387 + if len(src.Tags) > 0 {
388 + dst.Tags = make(map[string][]string, len(src.Tags))
389 + for key, values := range src.Tags {
390 + dst.Tags[key] = append([]string(nil), values...)
391 }
392 }
269 - return nil
393 + return dst
394 }
395
396 func (c Config) primarySubscriptionID() string {
397 for _, id := range c.SubscriptionIDs {
274 - if v := strings.TrimSpace(id); v != "" {
398 + if v := stringsTrim(id); v != "" {
399 return v
400 }
401 }
@@ -281,7 +405,7 @@ func (c Config) primarySubscriptionID() string {
405 func (c Config) subscriptionIDs() []string {
406 out := make([]string, 0, len(c.SubscriptionIDs))
407 for _, id := range c.SubscriptionIDs {
284 - if v := strings.TrimSpace(id); v != "" {
408 + if v := stringsTrim(id); v != "" {
409 out = append(out, v)
410 }
411 }
src/go/plugin/go.d/collector/azure_monitor/config_schema.json
+232 -19
@@ -158,6 +158,67 @@
158 "properties": {
159 "mode": {
160 "const": "auto"
161 + },
162 + "mode_auto": {
163 + "title": "Auto profile overrides",
164 + "type": "object",
165 + "properties": {
166 + "entries": {
167 + "title": "Profile entries",
168 + "description": "Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. Unmatched entries stay dormant.",
169 + "type": "array",
170 + "items": {
171 + "type": "object",
172 + "properties": {
173 + "name": {
174 + "title": "Profile basename",
175 + "description": "Canonical profile basename. Must be lowercase letters, digits, or underscores and start with a letter.",
176 + "type": "string",
177 + "pattern": "^[a-z][a-z0-9_]*$"
178 + },
179 + "filters": {
180 + "title": "Filters",
181 + "description": "Optional per-profile resource filters that narrow the globally discovered set for this profile.",
182 + "type": "object",
183 + "properties": {
184 + "resource_groups": {
185 + "title": "Resource groups",
186 + "type": "array",
187 + "items": {
188 + "type": "string",
189 + "minLength": 1
190 + }
191 + },
192 + "regions": {
193 + "title": "Regions",
194 + "type": "array",
195 + "items": {
196 + "type": "string",
197 + "minLength": 1
198 + }
199 + },
200 + "tags": {
201 + "title": "Tags",
202 + "description": "Optional tag filters for this profile. Supported only when `discovery.mode` is `filters`; in `query` mode, encode per-profile tag filtering in the KQL.",
203 + "type": "object",
204 + "additionalProperties": {
205 + "type": "array",
206 + "items": {
207 + "type": "string",
208 + "minLength": 1
209 + },
210 + "minItems": 1
211 + }
212 + }
213 + }
214 + }
215 + },
216 + "required": [
217 + "name"
218 + ]
219 + }
220 + }
221 + }
222 }
223 }
224 },
@@ -170,17 +231,64 @@
231 "title": "Exact profiles",
232 "type": "object",
233 "properties": {
173 - "names": {
174 - "title": "Profile basenames",
175 - "description": "Explicit profile file basenames used by `exact` mode. Matching is case-insensitive.",
234 + "entries": {
235 + "title": "Profile entries",
236 + "description": "Explicit profile entries used by `exact` mode.",
237 "type": "array",
238 "items": {
178 - "type": "string"
239 + "type": "object",
240 + "properties": {
241 + "name": {
242 + "title": "Profile basename",
243 + "description": "Canonical profile basename. Must be lowercase letters, digits, or underscores and start with a letter.",
244 + "type": "string",
245 + "pattern": "^[a-z][a-z0-9_]*$"
246 + },
247 + "filters": {
248 + "title": "Filters",
249 + "description": "Optional per-profile resource filters that narrow the globally discovered set for this profile.",
250 + "type": "object",
251 + "properties": {
252 + "resource_groups": {
253 + "title": "Resource groups",
254 + "type": "array",
255 + "items": {
256 + "type": "string",
257 + "minLength": 1
258 + }
259 + },
260 + "regions": {
261 + "title": "Regions",
262 + "type": "array",
263 + "items": {
264 + "type": "string",
265 + "minLength": 1
266 + }
267 + },
268 + "tags": {
269 + "title": "Tags",
270 + "description": "Optional tag filters for this profile. Supported only when `discovery.mode` is `filters`; in `query` mode, encode per-profile tag filtering in the KQL.",
271 + "type": "object",
272 + "additionalProperties": {
273 + "type": "array",
274 + "items": {
275 + "type": "string",
276 + "minLength": 1
277 + },
278 + "minItems": 1
279 + }
280 + }
281 + }
282 + }
283 + },
284 + "required": [
285 + "name"
286 + ]
287 }
288 }
289 },
290 "required": [
183 - "names"
291 + "entries"
292 ]
293 }
294 },
@@ -197,17 +305,64 @@
305 "title": "Combined profiles",
306 "type": "object",
307 "properties": {
200 - "names": {
201 - "title": "Profile basenames",
202 - "description": "Explicit profile file basenames merged with auto-discovered profiles in `combined` mode. Matching is case-insensitive.",
308 + "entries": {
309 + "title": "Profile entries",
310 + "description": "Explicit profile entries merged with auto-discovered profiles in `combined` mode.",
311 "type": "array",
312 "items": {
205 - "type": "string"
313 + "type": "object",
314 + "properties": {
315 + "name": {
316 + "title": "Profile basename",
317 + "description": "Canonical profile basename. Must be lowercase letters, digits, or underscores and start with a letter.",
318 + "type": "string",
319 + "pattern": "^[a-z][a-z0-9_]*$"
320 + },
321 + "filters": {
322 + "title": "Filters",
323 + "description": "Optional per-profile resource filters that narrow the globally discovered set for this profile.",
324 + "type": "object",
325 + "properties": {
326 + "resource_groups": {
327 + "title": "Resource groups",
328 + "type": "array",
329 + "items": {
330 + "type": "string",
331 + "minLength": 1
332 + }
333 + },
334 + "regions": {
335 + "title": "Regions",
336 + "type": "array",
337 + "items": {
338 + "type": "string",
339 + "minLength": 1
340 + }
341 + },
342 + "tags": {
343 + "title": "Tags",
344 + "description": "Optional tag filters for this profile. Supported only when `discovery.mode` is `filters`; in `query` mode, encode per-profile tag filtering in the KQL.",
345 + "type": "object",
346 + "additionalProperties": {
347 + "type": "array",
348 + "items": {
349 + "type": "string",
350 + "minLength": 1
351 + },
352 + "minItems": 1
353 + }
354 + }
355 + }
356 + }
357 + },
358 + "required": [
359 + "name"
360 + ]
361 }
362 }
363 },
364 "required": [
210 - "names"
365 + "entries"
366 ]
367 }
368 },
@@ -431,29 +586,87 @@
586 },
587 "profiles": {
588 "mode": {
434 - "ui:help": "Choose how metric profiles are activated: `auto` enables profiles for discovered resource types, `exact` uses only the listed profile basenames, and `combined` merges both.",
589 + "ui:help": "Choose how metric profiles are activated: `auto` enables profiles for discovered resource types, `exact` uses only the listed profile entries, and `combined` merges both.",
590 "ui:widget": "radio",
591 "ui:options": {
592 "inline": true
593 }
594 },
595 + "mode_auto": {
596 + "entries": {
597 + "ui:listFlavour": "list",
598 + "ui:help": "Optional per-profile overrides applied only to profiles that auto-activate at bootstrap.",
599 + "items": {
600 + "name": {
601 + "ui:help": "Canonical profile basename.",
602 + "ui:placeholder": "sql_database"
603 + },
604 + "filters": {
605 + "resource_groups": {
606 + "ui:listFlavour": "list"
607 + },
608 + "regions": {
609 + "ui:listFlavour": "list"
610 + },
611 + "tags": {
612 + "ui:help": "Only supported when `discovery.mode` is `filters`. In `query` mode, encode per-profile tag filtering in the KQL.",
613 + "additionalProperties": {
614 + "ui:listFlavour": "list"
615 + }
616 + }
617 + }
618 + }
619 + }
620 + },
621 "mode_exact": {
441 - "names": {
622 + "entries": {
623 "ui:listFlavour": "list",
443 - "ui:help": "Add Azure Monitor profile file basenames to enable explicitly in `exact` mode. Matching is case-insensitive.",
624 + "ui:help": "Add explicit profile entries for `exact` mode.",
625 "items": {
445 - "ui:help": "Profile file basename from the Azure Monitor profile catalog.",
446 - "ui:placeholder": "sql_database"
626 + "name": {
627 + "ui:help": "Canonical profile basename.",
628 + "ui:placeholder": "sql_database"
629 + },
630 + "filters": {
631 + "resource_groups": {
632 + "ui:listFlavour": "list"
633 + },
634 + "regions": {
635 + "ui:listFlavour": "list"
636 + },
637 + "tags": {
638 + "ui:help": "Only supported when `discovery.mode` is `filters`. In `query` mode, encode per-profile tag filtering in the KQL.",
639 + "additionalProperties": {
640 + "ui:listFlavour": "list"
641 + }
642 + }
643 + }
644 }
645 }
646 },
647 "mode_combined": {
451 - "names": {
648 + "entries": {
649 "ui:listFlavour": "list",
453 - "ui:help": "Add profile file basenames to merge with auto-discovered profiles in `combined` mode. Matching is case-insensitive.",
650 + "ui:help": "Add explicit profile entries to merge with auto-discovered profiles in `combined` mode.",
651 "items": {
455 - "ui:help": "Profile file basename from the Azure Monitor profile catalog.",
456 - "ui:placeholder": "sql_database"
652 + "name": {
653 + "ui:help": "Canonical profile basename.",
654 + "ui:placeholder": "sql_database"
655 + },
656 + "filters": {
657 + "resource_groups": {
658 + "ui:listFlavour": "list"
659 + },
660 + "regions": {
661 + "ui:listFlavour": "list"
662 + },
663 + "tags": {
664 + "ui:help": "Only supported when `discovery.mode` is `filters`. In `query` mode, encode per-profile tag filtering in the KQL.",
665 + "additionalProperties": {
666 + "ui:listFlavour": "list"
667 + }
668 + }
669 + }
670 }
671 }
672 }
src/go/plugin/go.d/collector/azure_monitor/discover.go
+241 -56
@@ -20,6 +20,19 @@ type discoveryFetchResult struct {
20 UnsupportedTypes []string
21 }
22
23 +type normalizedTagFilter struct {
24 + Key string
25 + Values []string
26 +}
27 +
28 +type profileResourceMatcher struct {
29 + profileName string
30 + resourceType string
31 + resourceGroups map[string]struct{}
32 + regions map[string]struct{}
33 + tagFilters []normalizedTagFilter
34 +}
35 +
36 func (c *Collector) refreshDiscovery(ctx context.Context, force bool) ([]resourceInfo, error) {
37 now := c.now()
38 if !force && !c.discovery.FetchedAt.IsZero() {
@@ -36,21 +49,25 @@ func (c *Collector) refreshDiscovery(ctx context.Context, force bool) ([]resourc
49 c.Warningf("ignoring unsupported discovered resource types: %v", fetched.UnsupportedTypes)
50 }
51
39 - resources, byType := filterDiscoveryResourcesByTypes(fetched.Resources, runtimeResourceTypes(c.runtime))
40 -
41 - if !slices.Equal(resources, c.discovery.Resources) {
42 - c.Infof("discovered %d resources: %v", len(resources), resources)
52 + state := buildDiscoveryState(fetched.Resources, c.runtime, now, c.Discovery.RefreshEvery, c.discovery.FetchCounter+1)
53 + if !equalResourceSlices(state.Resources, c.discovery.Resources) {
54 + c.Infof("discovered %d resources: %v", len(state.Resources), state.Resources)
55 }
56
45 - c.discovery = discoveryState{
46 - Resources: resources,
57 + c.discovery = state
58 + return state.Resources, nil
59 +}
60 +
61 +func buildDiscoveryState(resources []resourceInfo, runtime *collectorRuntime, now time.Time, refreshEvery int, fetchCounter uint64) discoveryState {
62 + filteredResources, byType := filterDiscoveryResourcesByTypes(resources, runtimeResourceTypes(runtime))
63 + return discoveryState{
64 + Resources: filteredResources,
65 ByType: byType,
66 + ByProfile: filterDiscoveryResourcesByProfiles(filteredResources, runtime),
67 FetchedAt: now,
49 - ExpiresAt: discoveryExpiresAt(now, c.Discovery.RefreshEvery),
50 - FetchCounter: c.discovery.FetchCounter + 1,
68 + ExpiresAt: discoveryExpiresAt(now, refreshEvery),
69 + FetchCounter: fetchCounter,
70 }
52 -
53 - return resources, nil
71 }
72
73 func discoveryExpiresAt(now time.Time, refreshEvery int) time.Time {
@@ -109,7 +126,7 @@ func runtimeResourceTypes(runtime *collectorRuntime) []string {
126 return resourceTypes
127 }
128
112 -func discoverResources(ctx context.Context, subscriptionIDs []string, timeout time.Duration, resourceGraph resourceGraphClient, resourceTypes []string, filters *DiscoveryFiltersConfig) ([]resourceInfo, map[string][]resourceInfo, error) {
129 +func discoverResources(ctx context.Context, subscriptionIDs []string, timeout time.Duration, resourceGraph resourceGraphClient, resourceTypes []string, filters *ResourceFiltersConfig) ([]resourceInfo, map[string][]resourceInfo, error) {
130 if len(resourceTypes) == 0 {
131 return nil, map[string][]resourceInfo{}, nil
132 }
@@ -119,11 +136,11 @@ func discoverResources(ctx context.Context, subscriptionIDs []string, timeout ti
136 return nil, nil, fmt.Errorf("failed to build resource discovery query")
137 }
138
122 - resourceGroupsFilter := normalizedDiscoveryFilterSet(nil)
123 - regionsFilter := normalizedDiscoveryFilterSet(nil)
139 + resourceGroupsFilter := normalizedFilterSet(nil)
140 + regionsFilter := normalizedFilterSet(nil)
141 if filters != nil {
125 - resourceGroupsFilter = normalizedDiscoveryFilterSet(filters.ResourceGroups)
126 - regionsFilter = normalizedDiscoveryFilterSet(filters.Regions)
142 + resourceGroupsFilter = normalizedFilterSet(filters.ResourceGroups)
143 + regionsFilter = normalizedFilterSet(filters.Regions)
144 }
145
146 result := make([]resourceInfo, 0, 256)
@@ -149,15 +166,15 @@ func discoverResources(ctx context.Context, subscriptionIDs []string, timeout ti
166 if id == "" {
167 continue
168 }
152 - if _, ok := seenIDs[id]; ok {
169 + idKey := stringsLowerTrim(id)
170 + if _, ok := seenIDs[idKey]; ok {
171 continue
172 }
155 - seenIDs[id] = struct{}{}
173 + seenIDs[idKey] = struct{}{}
174
175 rg := stringsTrim(asString(row["resourceGroup"]))
158 - rgLower := stringsLowerTrim(rg)
176 if len(resourceGroupsFilter) > 0 {
160 - if _, ok := resourceGroupsFilter[rgLower]; !ok {
177 + if _, ok := resourceGroupsFilter[stringsLowerTrim(rg)]; !ok {
178 continue
179 }
180 }
@@ -167,6 +184,7 @@ func discoverResources(ctx context.Context, subscriptionIDs []string, timeout ti
184 if resourceType == "" || !ok {
185 continue
186 }
187 +
188 region := stringsLowerTrim(asString(row["location"]))
189 if region == "" {
190 region = "global"
@@ -185,6 +203,7 @@ func discoverResources(ctx context.Context, subscriptionIDs []string, timeout ti
203 Type: resourceType,
204 ResourceGroup: rg,
205 Region: region,
206 + Tags: normalizeResourceTags(row["tags"]),
207 })
208 }
209
@@ -195,13 +214,8 @@ func discoverResources(ctx context.Context, subscriptionIDs []string, timeout ti
214 skipToken = &token
215 }
216
198 - byType := make(map[string][]resourceInfo)
199 - for _, r := range result {
200 - key := stringsLowerTrim(r.Type)
201 - byType[key] = append(byType[key], r)
202 - }
203 -
204 - return result, byType, nil
217 + sortResourceInfos(result)
218 + return result, indexResourcesByType(result), nil
219 }
220
221 func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, timeout time.Duration, resourceGraph resourceGraphClient, kql string, supportedTypes map[string]struct{}) (discoveryFetchResult, error) {
@@ -211,7 +225,6 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
225 }
226
227 result := make([]resourceInfo, 0, 256)
214 - byType := make(map[string][]resourceInfo)
228 unsupported := make(map[string]struct{})
229 seenIDs := make(map[string]struct{})
230
@@ -244,7 +257,6 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
257
258 result = append(result, resource)
259 typeKey := stringsLowerTrim(resource.Type)
247 - byType[typeKey] = append(byType[typeKey], resource)
260 if _, ok := supportedTypes[typeKey]; !ok {
261 unsupported[typeKey] = struct{}{}
262 }
@@ -257,6 +269,8 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
269 skipToken = &token
270 }
271
272 + sortResourceInfos(result)
273 +
274 unsupportedTypes := make([]string, 0, len(unsupported))
275 for resourceType := range unsupported {
276 unsupportedTypes = append(unsupportedTypes, resourceType)
@@ -265,7 +279,7 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
279
280 return discoveryFetchResult{
281 Resources: result,
268 - ByType: byType,
282 + ByType: indexResourcesByType(result),
283 UnsupportedTypes: unsupportedTypes,
284 }, nil
285 }
@@ -329,10 +343,11 @@ func parseARMResourceID(resourceID string) (string, bool) {
343 return stringsTrim(parts[1]), true
344 }
345
332 -func buildDiscoveryQuery(resourceTypes []string, filters *DiscoveryFiltersConfig) string {
346 +func buildDiscoveryQuery(resourceTypes []string, filters *ResourceFiltersConfig) string {
347 if len(resourceTypes) == 0 {
348 return ""
349 }
350 +
351 quotedTypes := make([]string, 0, len(resourceTypes))
352 for _, rt := range resourceTypes {
353 rt = stringsTrim(rt)
@@ -351,10 +366,10 @@ func buildDiscoveryQuery(resourceTypes []string, filters *DiscoveryFiltersConfig
366 query := "resources | where type in~ (" + strings.Join(quotedTypes, ", ") + ")"
367
368 if filters == nil {
354 - return query + " | project id, name, type, resourceGroup, location"
369 + return query + " | project id, name, type, resourceGroup, location, tags"
370 }
371
357 - if groups := normalizeDiscoveryFilterValues(filters.ResourceGroups); len(groups) > 0 {
372 + if groups := normalizeFilterValues(filters.ResourceGroups); len(groups) > 0 {
373 quotedGroups := make([]string, 0, len(groups))
374 for _, rg := range groups {
375 quotedGroups = append(quotedGroups, quoteKQLString(rg))
@@ -362,7 +377,7 @@ func buildDiscoveryQuery(resourceTypes []string, filters *DiscoveryFiltersConfig
377 query += " | where resourceGroup in~ (" + strings.Join(quotedGroups, ", ") + ")"
378 }
379
365 - if regions := normalizeDiscoveryFilterValues(filters.Regions); len(regions) > 0 {
380 + if regions := normalizeFilterValues(filters.Regions); len(regions) > 0 {
381 quotedRegions := make([]string, 0, len(regions))
382 for _, region := range regions {
383 quotedRegions = append(quotedRegions, quoteKQLString(region))
@@ -370,20 +385,20 @@ func buildDiscoveryQuery(resourceTypes []string, filters *DiscoveryFiltersConfig
385 query += " | where location in~ (" + strings.Join(quotedRegions, ", ") + ")"
386 }
387
373 - if tagFilters := normalizeDiscoveryTagFilters(filters.Tags); len(tagFilters) > 0 {
388 + if tagFilters := normalizeTagFilters(filters.Tags); len(tagFilters) > 0 {
389 + query += " | extend tagsBag = tags"
390 query += " | mv-expand bagexpansion=array tags"
391 query += " | where isnotempty(tags)"
392 query += " | extend tagKey = tostring(tags[0]), tagValue = tostring(tags[1])"
377 - query += " | where " + buildDiscoveryTagPredicate(tagFilters)
378 - query += " | summarize by id, name, type, resourceGroup, location, matchedTagKey = tolower(tagKey)"
379 - query += " | summarize matchedTagKeys = count() by id, name, type, resourceGroup, location"
393 + query += " | where " + buildTagPredicate(tagFilters)
394 + query += " | summarize tags = take_any(tagsBag), matchedTagKeys = dcount(tolower(tagKey)) by id, name, type, resourceGroup, location"
395 query += fmt.Sprintf(" | where matchedTagKeys == %d", len(tagFilters))
396 }
397
383 - return query + " | project id, name, type, resourceGroup, location"
398 + return query + " | project id, name, type, resourceGroup, location, tags"
399 }
400
386 -func normalizeDiscoveryFilterValues(values []string) []string {
401 +func normalizeFilterValues(values []string) []string {
402 seen := make(map[string]struct{}, len(values))
403 out := make([]string, 0, len(values))
404
@@ -403,17 +418,12 @@ func normalizeDiscoveryFilterValues(values []string) []string {
418 return out
419 }
420
406 -type discoveryTagFilter struct {
407 - Key string
408 - Values []string
409 -}
410 -
411 -func normalizeDiscoveryTagFilters(tags map[string][]string) []discoveryTagFilter {
421 +func normalizeTagFilters(tags map[string][]string) []normalizedTagFilter {
422 if len(tags) == 0 {
423 return nil
424 }
425
416 - out := make([]discoveryTagFilter, 0, len(tags))
426 + out := make([]normalizedTagFilter, 0, len(tags))
427 for key, values := range tags {
428 normalizedKey := stringsLowerTrim(key)
429 if normalizedKey == "" {
@@ -438,10 +448,10 @@ func normalizeDiscoveryTagFilters(tags map[string][]string) []discoveryTagFilter
448 }
449
450 slices.Sort(normalizedValues)
441 - out = append(out, discoveryTagFilter{Key: normalizedKey, Values: normalizedValues})
451 + out = append(out, normalizedTagFilter{Key: normalizedKey, Values: normalizedValues})
452 }
453
444 - slices.SortFunc(out, func(a, b discoveryTagFilter) int {
454 + slices.SortFunc(out, func(a, b normalizedTagFilter) int {
455 switch {
456 case a.Key < b.Key:
457 return -1
@@ -454,17 +464,17 @@ func normalizeDiscoveryTagFilters(tags map[string][]string) []discoveryTagFilter
464 return out
465 }
466
457 -func buildDiscoveryTagPredicate(filters []discoveryTagFilter) string {
467 +func buildTagPredicate(filters []normalizedTagFilter) string {
468 clauses := make([]string, 0, len(filters))
469 for _, filter := range filters {
470 keyClause := "tagKey =~ " + quoteKQLString(filter.Key)
461 - valueClause := buildDiscoveryTagValueClause(filter.Values)
471 + valueClause := buildTagValueClause(filter.Values)
472 clauses = append(clauses, "("+keyClause+" and "+valueClause+")")
473 }
474 return strings.Join(clauses, " or ")
475 }
476
467 -func buildDiscoveryTagValueClause(values []string) string {
477 +func buildTagValueClause(values []string) string {
478 if len(values) == 1 {
479 return "tagValue == " + quoteKQLString(values[0])
480 }
@@ -476,13 +486,13 @@ func buildDiscoveryTagValueClause(values []string) string {
486 return "tagValue in (" + strings.Join(quoted, ", ") + ")"
487 }
488
479 -func normalizedDiscoveryFilterSet(values []string) map[string]struct{} {
489 +func normalizedFilterSet(values []string) map[string]struct{} {
490 if len(values) == 0 {
491 return nil
492 }
493
494 set := make(map[string]struct{}, len(values))
485 - for _, value := range normalizeDiscoveryFilterValues(values) {
495 + for _, value := range normalizeFilterValues(values) {
496 set[value] = struct{}{}
497 }
498 return set
@@ -561,17 +571,89 @@ func filterDiscoveryResourcesByTypes(resources []resourceInfo, allowedTypes []st
571 }
572
573 filtered := make([]resourceInfo, 0, len(resources))
564 - byType := make(map[string][]resourceInfo)
574 for _, resource := range resources {
575 typeKey := stringsLowerTrim(resource.Type)
576 if _, ok := allowed[typeKey]; !ok {
577 continue
578 }
579 filtered = append(filtered, resource)
571 - byType[typeKey] = append(byType[typeKey], resource)
580 }
581
574 - return filtered, byType
582 + return filtered, indexResourcesByType(filtered)
583 +}
584 +
585 +func filterDiscoveryResourcesByProfiles(resources []resourceInfo, runtime *collectorRuntime) map[string][]resourceInfo {
586 + if runtime == nil || len(runtime.Profiles) == 0 {
587 + return map[string][]resourceInfo{}
588 + }
589 +
590 + result := make(map[string][]resourceInfo, len(runtime.Profiles))
591 + for _, profile := range runtime.Profiles {
592 + matcher := newProfileResourceMatcher(profile)
593 + for _, resource := range resources {
594 + if !matcher.matches(resource) {
595 + continue
596 + }
597 + result[profile.Name] = append(result[profile.Name], resource)
598 + }
599 + }
600 + return result
601 +}
602 +
603 +func newProfileResourceMatcher(profile *profileRuntime) profileResourceMatcher {
604 + matcher := profileResourceMatcher{
605 + resourceType: stringsLowerTrim(profile.ResourceType),
606 + profileName: profile.Name,
607 + }
608 + if profile.Filters == nil {
609 + return matcher
610 + }
611 +
612 + matcher.resourceGroups = normalizedFilterSet(profile.Filters.ResourceGroups)
613 + matcher.regions = normalizedFilterSet(profile.Filters.Regions)
614 + matcher.tagFilters = normalizeTagFilters(profile.Filters.Tags)
615 + return matcher
616 +}
617 +
618 +func (m profileResourceMatcher) matches(resource resourceInfo) bool {
619 + if stringsLowerTrim(resource.Type) != m.resourceType {
620 + return false
621 + }
622 + if len(m.resourceGroups) > 0 {
623 + if _, ok := m.resourceGroups[stringsLowerTrim(resource.ResourceGroup)]; !ok {
624 + return false
625 + }
626 + }
627 + if len(m.regions) > 0 {
628 + if _, ok := m.regions[normalizeRegion(resource.Region)]; !ok {
629 + return false
630 + }
631 + }
632 + for _, tagFilter := range m.tagFilters {
633 + if !resourceMatchesTag(resource, tagFilter) {
634 + return false
635 + }
636 + }
637 + return true
638 +}
639 +
640 +func resourceMatchesTag(resource resourceInfo, filter normalizedTagFilter) bool {
641 + for _, tag := range resource.Tags {
642 + if tag.Key != filter.Key {
643 + continue
644 + }
645 + return slices.Contains(filter.Values, tag.Value)
646 + }
647 + return false
648 +}
649 +
650 +func indexResourcesByType(resources []resourceInfo) map[string][]resourceInfo {
651 + result := make(map[string][]resourceInfo)
652 + for _, resource := range resources {
653 + key := stringsLowerTrim(resource.Type)
654 + result[key] = append(result[key], resource)
655 + }
656 + return result
657 }
658
659 func catalogResourceTypeSet(catalog azureprofiles.Catalog) map[string]struct{} {
@@ -612,3 +694,106 @@ func asString(v any) string {
694 return ""
695 }
696 }
697 +
698 +func normalizeResourceTags(v any) []resourceTag {
699 + rawTags, ok := v.(map[string]any)
700 + if !ok {
701 + if typed, ok := v.(map[string]string); ok {
702 + rawTags = make(map[string]any, len(typed))
703 + for key, value := range typed {
704 + rawTags[key] = value
705 + }
706 + } else {
707 + return nil
708 + }
709 + }
710 +
711 + tags := make([]resourceTag, 0, len(rawTags))
712 + for key, value := range rawTags {
713 + tagKey := stringsLowerTrim(key)
714 + if tagKey == "" {
715 + continue
716 + }
717 + tags = append(tags, resourceTag{
718 + Key: tagKey,
719 + Value: normalizeResourceTagValue(value),
720 + })
721 + }
722 +
723 + slices.SortFunc(tags, func(a, b resourceTag) int {
724 + switch {
725 + case a.Key < b.Key:
726 + return -1
727 + case a.Key > b.Key:
728 + return 1
729 + case a.Value < b.Value:
730 + return -1
731 + case a.Value > b.Value:
732 + return 1
733 + default:
734 + return 0
735 + }
736 + })
737 + return tags
738 +}
739 +
740 +func normalizeResourceTagValue(v any) string {
741 + switch x := v.(type) {
742 + case nil:
743 + return ""
744 + case string:
745 + return stringsTrim(x)
746 + case fmt.Stringer:
747 + return stringsTrim(x.String())
748 + default:
749 + return stringsTrim(fmt.Sprint(x))
750 + }
751 +}
752 +
753 +func sortResourceInfos(resources []resourceInfo) {
754 + slices.SortFunc(resources, func(a, b resourceInfo) int {
755 + switch {
756 + case stringsLowerTrim(a.ID) < stringsLowerTrim(b.ID):
757 + return -1
758 + case stringsLowerTrim(a.ID) > stringsLowerTrim(b.ID):
759 + return 1
760 + default:
761 + return 0
762 + }
763 + })
764 +}
765 +
766 +func equalResourceSlices(a, b []resourceInfo) bool {
767 + if len(a) != len(b) {
768 + return false
769 + }
770 + for i := range a {
771 + if !equalResourceInfo(a[i], b[i]) {
772 + return false
773 + }
774 + }
775 + return true
776 +}
777 +
778 +func equalResourceInfo(a, b resourceInfo) bool {
779 + return a.SubscriptionID == b.SubscriptionID &&
780 + a.ID == b.ID &&
781 + a.UID == b.UID &&
782 + a.Name == b.Name &&
783 + a.Type == b.Type &&
784 + a.ResourceGroup == b.ResourceGroup &&
785 + a.Region == b.Region &&
786 + equalResourceTags(a.Tags, b.Tags)
787 +}
788 +
789 +func equalResourceTags(a, b []resourceTag) bool {
790 + if len(a) != len(b) {
791 + return false
792 + }
793 + for i := range a {
794 + if a[i] != b[i] {
795 + return false
796 + }
797 + }
798 + return true
799 +}
src/go/plugin/go.d/collector/azure_monitor/helpers.go
+20
@@ -10,6 +10,8 @@ import (
10 "time"
11 )
12
13 +const profileNamePattern = "^[a-z][a-z0-9_]*$"
14 +
15 func stringsLowerTrim(v string) string {
16 return strings.ToLower(strings.TrimSpace(v))
17 }
@@ -29,3 +31,21 @@ func withOptionalTimeout(ctx context.Context, timeout time.Duration) (context.Co
31 }
32 return context.WithTimeout(ctx, timeout)
33 }
34 +
35 +func isValidProfileName(v string) bool {
36 + v = stringsTrim(v)
37 + if len(v) == 0 {
38 + return false
39 + }
40 + if v[0] < 'a' || v[0] > 'z' {
41 + return false
42 + }
43 + for i := 1; i < len(v); i++ {
44 + c := v[i]
45 + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' {
46 + continue
47 + }
48 + return false
49 + }
50 + return true
51 +}
src/go/plugin/go.d/collector/azure_monitor/init.go
+66 -35
@@ -6,6 +6,7 @@ import (
6 "context"
7 "errors"
8 "fmt"
9 + "strings"
10
11 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
12 azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
@@ -23,6 +24,12 @@ type initResult struct {
24 supportedResourceTypes map[string]struct{}
25 }
26
27 +type initProfileSelection struct {
28 + Names []string
29 + AutoNames []string
30 + Entries map[string]ProfileEntryConfig
31 +}
32 +
33 func (c *Collector) initInstruments(runtime *collectorRuntime) error {
34 if runtime == nil {
35 return errors.New("nil collector runtime")
@@ -96,12 +103,12 @@ func (c *Collector) ensureBootstrapped(ctx context.Context) error {
103 c.Warningf("ignoring unsupported discovered resource types: %v", fetched.UnsupportedTypes)
104 }
105
99 - profileIDs, autoProfiles, err := resolveInitProfileIDs(c.Config, c.profileCatalog, fetched.ByType)
106 + selection, err := resolveInitProfiles(c.Config, c.profileCatalog, fetched.ByType)
107 if err != nil {
108 return err
109 }
103 - if len(autoProfiles) > 0 {
104 - c.Infof("auto-discovery resolved profiles: %v", autoProfiles)
110 + if len(selection.AutoNames) > 0 {
111 + c.Infof("auto-discovery resolved profiles: %v", selection.AutoNames)
112 }
113
114 // TODO(azure_monitor): Insert bootstrap-only metric-definition validation here.
@@ -110,26 +117,18 @@ func (c *Collector) ensureBootstrapped(ctx context.Context) error {
117 // lookup errors, and prune unsupported metrics/aggregations/time grains
118 // before final runtime build. Because runtime is currently global per job,
119 // multi-subscription capability differences need an explicit merge rule first.
113 - runtime, err := buildCollectorRuntimeFromConfig(profileIDs, c.profileCatalog)
120 + runtime, err := buildCollectorRuntimeFromConfig(selection.Names, selection.Entries, c.profileCatalog)
121 if err != nil {
122 return fmt.Errorf("build collector runtime: %w", err)
123 }
124 if err := c.initInstruments(runtime); err != nil {
125 return err
126 }
120 -
121 - resources, byType := filterDiscoveryResourcesByTypes(fetched.Resources, runtimeResourceTypes(runtime))
127 now := c.now()
128
129 c.runtime = runtime
130 c.observations = newObservationState(runtime.Instruments)
126 - c.discovery = discoveryState{
127 - Resources: resources,
128 - ByType: byType,
129 - FetchedAt: now,
130 - ExpiresAt: discoveryExpiresAt(now, c.Discovery.RefreshEvery),
131 - FetchCounter: 1,
132 - }
131 + c.discovery = buildDiscoveryState(fetched.Resources, runtime, now, c.Discovery.RefreshEvery, 1)
132
133 return nil
134 }
@@ -146,6 +145,14 @@ func (c *Collector) prepareInitConfig() (Config, azureprofiles.Catalog, error) {
145 if err := cfg.validate(); err != nil {
146 return Config{}, azureprofiles.Catalog{}, fmt.Errorf("config validation: %w", err)
147 }
148 + cfg, ignoredTagPaths := sanitizeIgnoredProfileTagFilters(cfg)
149 + if len(ignoredTagPaths) > 0 {
150 + c.Warningf(
151 + "ignoring profile tag filters in discovery.mode %q; encode per-profile tag filtering in discovery.mode_query.kql: %s",
152 + discoveryModeQuery,
153 + strings.Join(ignoredTagPaths, ", "),
154 + )
155 + }
156
157 return cfg, catalog, nil
158 }
@@ -211,13 +218,13 @@ func initDiscoveryResourceTypes(cfg Config, catalog azureprofiles.Catalog) ([]st
218 case profilesModeAuto, profilesModeCombined:
219 return catalog.ResourceTypes(), nil
220 case profilesModeExact:
214 - return catalog.ResourceTypesForProfileBaseNames(cfg.Profiles.explicitBaseNames())
221 + return catalog.ResourceTypesForProfileBaseNames(entryNames(modeEntries(cfg.Profiles.ModeExact)))
222 default:
223 return nil, fmt.Errorf("unsupported profiles.mode %q", cfg.Profiles.Mode)
224 }
225 }
226
220 -func resolveInitProfileIDs(cfg Config, catalog azureprofiles.Catalog, byType map[string][]resourceInfo) ([]string, []string, error) {
227 +func resolveInitProfiles(cfg Config, catalog azureprofiles.Catalog, byType map[string][]resourceInfo) (initProfileSelection, error) {
228 discoveredTypes := make(map[string]struct{}, len(byType))
229 for key := range byType {
230 discoveredTypes[key] = struct{}{}
@@ -227,23 +234,28 @@ func resolveInitProfileIDs(cfg Config, catalog azureprofiles.Catalog, byType map
234 switch stringsLowerTrim(cfg.Profiles.Mode) {
235 case profilesModeAuto:
236 if len(autoProfiles) == 0 {
230 - return nil, nil, errors.New("auto-discovery found no Azure resources matching any known profile")
237 + return initProfileSelection{}, errors.New("auto-discovery found no Azure resources matching any known profile")
238 }
232 - return autoProfiles, autoProfiles, nil
239 + return initProfileSelection{
240 + Names: autoProfiles,
241 + AutoNames: autoProfiles,
242 + Entries: filterEntryMap(entryMap(modeEntries(cfg.Profiles.ModeAuto)), autoProfiles),
243 + }, nil
244 case profilesModeExact:
234 - explicitProfileIDs, err := catalog.ProfileIDsForBaseNames(cfg.Profiles.explicitBaseNames())
235 - if err != nil {
236 - return nil, nil, err
237 - }
238 - return explicitProfileIDs, nil, nil
245 + explicitNames := entryNames(modeEntries(cfg.Profiles.ModeExact))
246 + return initProfileSelection{
247 + Names: explicitNames,
248 + Entries: entryMap(modeEntries(cfg.Profiles.ModeExact)),
249 + }, nil
250 case profilesModeCombined:
240 - explicitProfileIDs, err := catalog.ProfileIDsForBaseNames(cfg.Profiles.explicitBaseNames())
241 - if err != nil {
242 - return nil, nil, err
243 - }
244 - return mergeProfileIDs(explicitProfileIDs, autoProfiles), autoProfiles, nil
251 + explicitNames := entryNames(modeEntries(cfg.Profiles.ModeCombined))
252 + return initProfileSelection{
253 + Names: mergeProfileNames(explicitNames, autoProfiles),
254 + AutoNames: autoProfiles,
255 + Entries: entryMap(modeEntries(cfg.Profiles.ModeCombined)),
256 + }, nil
257 default:
246 - return nil, nil, fmt.Errorf("unsupported profiles.mode %q", cfg.Profiles.Mode)
258 + return initProfileSelection{}, fmt.Errorf("unsupported profiles.mode %q", cfg.Profiles.Mode)
259 }
260 }
261
@@ -257,28 +269,47 @@ func createCredential(auth cloudauth.AzureADAuthConfig, cloudCfg azcloud.Configu
269 })
270 }
271
260 -func mergeProfileIDs(explicit, discovered []string) []string {
272 +func mergeProfileNames(explicit, discovered []string) []string {
273 seen := make(map[string]struct{}, len(explicit)+len(discovered))
274 merged := make([]string, 0, len(explicit)+len(discovered))
263 - for _, id := range explicit {
264 - key := stringsLowerTrim(id)
275 + for _, name := range explicit {
276 + key := stringsLowerTrim(name)
277 if _, ok := seen[key]; ok {
278 continue
279 }
280 seen[key] = struct{}{}
269 - merged = append(merged, id)
281 + merged = append(merged, name)
282 }
271 - for _, id := range discovered {
272 - key := stringsLowerTrim(id)
283 + for _, name := range discovered {
284 + key := stringsLowerTrim(name)
285 if _, ok := seen[key]; ok {
286 continue
287 }
288 seen[key] = struct{}{}
277 - merged = append(merged, id)
289 + merged = append(merged, name)
290 }
291 return merged
292 }
293
294 +func filterEntryMap(entries map[string]ProfileEntryConfig, active []string) map[string]ProfileEntryConfig {
295 + if len(entries) == 0 || len(active) == 0 {
296 + return nil
297 + }
298 +
299 + filtered := make(map[string]ProfileEntryConfig)
300 + for _, name := range active {
301 + entry, ok := entries[name]
302 + if !ok {
303 + continue
304 + }
305 + filtered[name] = entry
306 + }
307 + if len(filtered) == 0 {
308 + return nil
309 + }
310 + return filtered
311 +}
312 +
313 func defaultNewResourceGraphClient(subscriptionID string, cred azcore.TokenCredential, cloudCfg azcloud.Configuration) (resourceGraphClient, error) {
314 _ = subscriptionID
315 client, err := armresourcegraph.NewClient(cred, armClientOptions{Cloud: cloudCfg}.toARM())
src/go/plugin/go.d/collector/azure_monitor/metadata.yaml
+144 -55
@@ -29,7 +29,7 @@ modules:
29
30 - **Multi-subscription** -- monitor resources across one or more Azure subscriptions in a single job
31 - **Automatic service detection** -- discovers resources and enables matching metric profiles without manual configuration
32 - - **38 built-in service profiles** -- covers databases, compute, networking, storage, AI, analytics, and more
32 + - **[38 built-in service profiles](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default)** -- covers databases, compute, networking, storage, AI, analytics, and more
33 - **Flexible discovery** -- use structured filters (resource groups, regions, tags) or a custom Azure Resource Graph KQL query
34 method_description: &method_description |
35 It uses the [Azure Monitor Metrics batch API](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-batch-api) to collect metrics, grouping requests by subscription, region, and time grain. Resources are discovered via [Azure Resource Graph](https://learn.microsoft.com/en-us/azure/governance/resource-graph/overview) queries at startup and refreshed periodically. Authentication is handled through [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/) (service principal, managed identity, or default credentials).
@@ -53,7 +53,7 @@ modules:
53 **Bootstrap (first run)**
54
55 - With the default `profiles.mode: auto`, the collector queries Azure Resource Graph within the configured `subscription_ids` to find candidate resources.
56 - - It matches discovered resource types against built-in profiles and automatically enables the relevant ones.
56 + - It matches discovered resource types against [built-in profiles](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default) and automatically enables the relevant ones.
57 - Discovery scope can be narrowed using `discovery.mode: filters` (resource groups, regions, tags) or replaced entirely with `discovery.mode: query` for a custom KQL query.
58 - A single job can monitor multiple subscriptions.
59
@@ -131,7 +131,7 @@ modules:
131 | Stock profiles | `/usr/lib/netdata/conf.d/go.d/azure_monitor.profiles/default/` |
132 | User overrides | `/etc/netdata/go.d/azure_monitor.profiles/` |
133
134 - User profile files with the same `id` as a stock profile override it.
134 + User profile files with the same basename as a [stock profile](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default) override it.
135 Custom profiles extend the collector's catalog -- they do not replace the discovery mechanism.
136 folding:
137 title: Config options
@@ -248,6 +248,7 @@ modules:
248 group: Discovery
249 detailed_description: |
250 A raw Azure Resource Graph KQL query used when `discovery.mode` is `query`.
251 + See the [Azure Resource Graph query language documentation](https://learn.microsoft.com/en-us/azure/governance/resource-graph/concepts/query-language) for syntax and supported operators.
252
253 The query **must** project these five columns:
254
@@ -259,6 +260,14 @@ modules:
260 | `resourceGroup` | Resource group name |
261 | `location` | Azure region |
262
263 + :::info
264 +
265 + - Returned resource `type` values should match the Azure resource types expected by the active profiles.
266 + - Resources with unsupported or non-matching types are ignored and do not activate profiles.
267 + - For the stock catalog, see [built-in profiles](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default).
268 +
269 + :::
270 +
271 Example:
272
273 ```
@@ -276,21 +285,70 @@ modules:
285
286 | Mode | Behavior |
287 |:-----|:---------|
279 - | `auto` | Discovers resource types in your subscriptions and enables matching built-in profiles automatically. This is the default. |
280 - | `exact` | Uses only the profile basenames listed under `profiles.mode_exact.names`. No auto-discovery. |
281 - | `combined` | Merges auto-discovered profiles with the basenames listed under `profiles.mode_combined.names`. |
288 + | `auto` | Discovers resource types in your subscriptions and enables matching [built-in profiles](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default) automatically. This is the default. |
289 + | `exact` | Uses only the profile entries listed under `profiles.mode_exact.entries`. No auto-discovery. |
290 + | `combined` | Merges auto-discovered profiles with the explicit entries listed under `profiles.mode_combined.entries`. |
291 +
292 + Each profile entry uses `name` as the canonical profile basename. The basename is the profile filename without the `.yaml` / `.yml` suffix and must be lowercase.
293
283 - Profile basename matching is case-insensitive. A basename is the profile filename without the `.yaml` / `.yml` suffix.
284 - - name: profiles.mode_exact.names
285 - description: Explicit profile file basenames used by `exact` mode. Matching is case-insensitive.
294 + Filter layering:
295 +
296 + - `discovery.mode_filters.*` defines the job-wide discovery scope.
297 + - `profiles.mode_*.entries[].filters.*` narrows resources for one profile only.
298 + - The effective resource set for a profile is the intersection of both filter sets.
299 + - Per-profile filters never widen or bypass the global discovery scope.
300 + - In `discovery.mode: query`, per-profile filters can use only `resource_groups` and `regions`. If you need per-profile tag filtering there, encode it in the KQL.
301 + - name: profiles.mode_auto.entries
302 + description: Optional per-profile overrides applied only to profiles that auto-activate at bootstrap.
303 + default_value: "[]"
304 + required: false
305 + group: Profiles
306 + detailed_description: |
307 + Each entry has:
308 +
309 + | Field | Description |
310 + |:------|:------------|
311 + | `name` | Canonical profile basename. |
312 + | `filters.resource_groups` | Optional resource-group narrowing for this profile. |
313 + | `filters.regions` | Optional region narrowing for this profile. |
314 + | `filters.tags` | Optional tag narrowing for this profile in `discovery.mode: filters`. |
315 +
316 + Auto-mode entries do not activate profiles on their own. They only override filtering for profiles that were auto-selected at bootstrap.
317 + In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
318 + - name: profiles.mode_exact.entries
319 + description: Explicit profile entries used by `exact` mode.
320 default_value: "[]"
321 required: false
322 group: Profiles
289 - - name: profiles.mode_combined.names
290 - description: Explicit profile file basenames merged with auto-discovered profiles in `combined` mode. Matching is case-insensitive.
323 + detailed_description: |
324 + Each entry has:
325 +
326 + | Field | Description |
327 + |:------|:------------|
328 + | `name` | Canonical profile basename. |
329 + | `filters.resource_groups` | Optional resource-group narrowing for this profile. |
330 + | `filters.regions` | Optional region narrowing for this profile. |
331 + | `filters.tags` | Optional tag narrowing for this profile in `discovery.mode: filters`. |
332 +
333 + Per-profile filters only narrow the globally discovered resource set. They never widen it.
334 + In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
335 + - name: profiles.mode_combined.entries
336 + description: Explicit profile entries merged with auto-discovered profiles in `combined` mode.
337 default_value: "[]"
338 required: false
339 group: Profiles
340 + detailed_description: |
341 + Each entry has:
342 +
343 + | Field | Description |
344 + |:------|:------------|
345 + | `name` | Canonical profile basename. |
346 + | `filters.resource_groups` | Optional resource-group narrowing for this profile. |
347 + | `filters.regions` | Optional region narrowing for this profile. |
348 + | `filters.tags` | Optional tag narrowing for this profile in `discovery.mode: filters`. |
349 +
350 + If an explicit `combined` entry matches an auto-selected profile, the collector keeps one runtime profile and overlays the explicit entry filters onto it.
351 + In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
352 - name: limits.max_concurrency
353 description: Maximum concurrent batch queries to Azure Monitor.
354 default_value: 4
@@ -354,9 +412,40 @@ modules:
412 profiles:
413 mode: exact
414 mode_exact:
357 - names:
358 - - sql_database
359 - - postgres_flexible
415 + entries:
416 + - name: sql_database
417 + - name: postgres_flexible
418 + auth:
419 + mode: managed_identity
420 + - name: Global and per-profile filters together
421 + description: Apply a global discovery boundary to the whole job, then narrow only the SQL Database profile further with a per-profile tag filter.
422 + config: |
423 + jobs:
424 + - name: prod-databases
425 + subscription_ids:
426 + - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
427 + discovery:
428 + mode: filters
429 + mode_filters:
430 + resource_groups:
431 + - production-rg
432 + regions:
433 + - eastus
434 + profiles:
435 + mode: combined
436 + mode_combined:
437 + entries:
438 + - name: sql_database
439 + filters:
440 + tags:
441 + env:
442 + - prod
443 + # Effective scope:
444 + # 1. Discovery first keeps only resources in production-rg and eastus.
445 + # 2. Auto-selected profiles use that global scope as-is.
446 + # 3. The sql_database profile is narrowed further to resources tagged env=prod.
447 + # 4. The sql_database profile cannot see resources outside production-rg/eastus,
448 + # because per-profile filters only narrow the globally discovered set.
449 auth:
450 mode: managed_identity
451 - name: Custom Azure Resource Graph KQL
@@ -412,8 +501,8 @@ modules:
501 description: |
502 Profiles are matched by Azure resource type. If a resource type exists but metrics are missing:
503
415 - - **Check profile mode** -- Ensure `profiles.mode: auto` (default), or explicitly list the profile basename under `profiles.mode_exact.names` or `profiles.mode_combined.names`.
416 - - **Verify a built-in profile exists** -- List available profiles:
504 + - **Check profile mode** -- Ensure `profiles.mode: auto` (default), or explicitly list the profile basename under `profiles.mode_exact.entries` or `profiles.mode_combined.entries`.
505 + - **Verify a [built-in profile](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default) exists** -- List available profiles:
506 ```bash
507 ls /usr/lib/netdata/conf.d/go.d/azure_monitor.profiles/default/
508 ```
@@ -512,7 +601,7 @@ modules:
601 - name: resource_type
602 description: "The Azure resource type identifier."
603 - name: profile
515 - description: "The Azure Monitor profile id."
604 + description: "The Azure Monitor profile basename."
605 - name: subscription_id
606 description: "The Azure subscription identifier."
607 - name: resource_uid
@@ -683,7 +772,7 @@ modules:
772 - name: resource_type
773 description: "The Azure resource type identifier."
774 - name: profile
686 - description: "The Azure Monitor profile id."
775 + description: "The Azure Monitor profile basename."
776 - name: subscription_id
777 description: "The Azure subscription identifier."
778 - name: resource_uid
@@ -969,7 +1058,7 @@ modules:
1058 - name: resource_type
1059 description: "The Azure resource type identifier."
1060 - name: profile
972 - description: "The Azure Monitor profile id."
1061 + description: "The Azure Monitor profile basename."
1062 - name: subscription_id
1063 description: "The Azure subscription identifier."
1064 - name: resource_uid
@@ -1357,7 +1446,7 @@ modules:
1446 - name: resource_type
1447 description: "The Azure resource type identifier."
1448 - name: profile
1360 - description: "The Azure Monitor profile id."
1449 + description: "The Azure Monitor profile basename."
1450 - name: subscription_id
1451 description: "The Azure subscription identifier."
1452 - name: resource_uid
@@ -1597,7 +1686,7 @@ modules:
1686 - name: resource_type
1687 description: "The Azure resource type identifier."
1688 - name: profile
1600 - description: "The Azure Monitor profile id."
1689 + description: "The Azure Monitor profile basename."
1690 - name: subscription_id
1691 description: "The Azure subscription identifier."
1692 - name: resource_uid
@@ -1859,7 +1948,7 @@ modules:
1948 - name: resource_type
1949 description: "The Azure resource type identifier."
1950 - name: profile
1862 - description: "The Azure Monitor profile id."
1951 + description: "The Azure Monitor profile basename."
1952 - name: subscription_id
1953 description: "The Azure subscription identifier."
1954 - name: resource_uid
@@ -2209,7 +2298,7 @@ modules:
2298 - name: resource_type
2299 description: "The Azure resource type identifier."
2300 - name: profile
2212 - description: "The Azure Monitor profile id."
2301 + description: "The Azure Monitor profile basename."
2302 - name: subscription_id
2303 description: "The Azure subscription identifier."
2304 - name: resource_uid
@@ -2444,7 +2533,7 @@ modules:
2533 - name: resource_type
2534 description: "The Azure resource type identifier."
2535 - name: profile
2447 - description: "The Azure Monitor profile id."
2536 + description: "The Azure Monitor profile basename."
2537 - name: subscription_id
2538 description: "The Azure subscription identifier."
2539 - name: resource_uid
@@ -2558,7 +2647,7 @@ modules:
2647 - name: resource_type
2648 description: "The Azure resource type identifier."
2649 - name: profile
2561 - description: "The Azure Monitor profile id."
2650 + description: "The Azure Monitor profile basename."
2651 - name: subscription_id
2652 description: "The Azure subscription identifier."
2653 - name: resource_uid
@@ -2704,7 +2793,7 @@ modules:
2793 - name: resource_type
2794 description: "The Azure resource type identifier."
2795 - name: profile
2707 - description: "The Azure Monitor profile id."
2796 + description: "The Azure Monitor profile basename."
2797 - name: subscription_id
2798 description: "The Azure subscription identifier."
2799 - name: resource_uid
@@ -2938,7 +3027,7 @@ modules:
3027 - name: resource_type
3028 description: "The Azure resource type identifier."
3029 - name: profile
2941 - description: "The Azure Monitor profile id."
3030 + description: "The Azure Monitor profile basename."
3031 - name: subscription_id
3032 description: "The Azure subscription identifier."
3033 - name: resource_uid
@@ -3219,7 +3308,7 @@ modules:
3308 - name: resource_type
3309 description: "The Azure resource type identifier."
3310 - name: profile
3222 - description: "The Azure Monitor profile id."
3311 + description: "The Azure Monitor profile basename."
3312 - name: subscription_id
3313 description: "The Azure subscription identifier."
3314 - name: resource_uid
@@ -3528,7 +3617,7 @@ modules:
3617 - name: resource_type
3618 description: "The Azure resource type identifier."
3619 - name: profile
3531 - description: "The Azure Monitor profile id."
3620 + description: "The Azure Monitor profile basename."
3621 - name: subscription_id
3622 description: "The Azure subscription identifier."
3623 - name: resource_uid
@@ -3726,7 +3815,7 @@ modules:
3815 - name: resource_type
3816 description: "The Azure resource type identifier."
3817 - name: profile
3729 - description: "The Azure Monitor profile id."
3818 + description: "The Azure Monitor profile basename."
3819 - name: subscription_id
3820 description: "The Azure subscription identifier."
3821 - name: resource_uid
@@ -3922,7 +4011,7 @@ modules:
4011 - name: resource_type
4012 description: "The Azure resource type identifier."
4013 - name: profile
3925 - description: "The Azure Monitor profile id."
4014 + description: "The Azure Monitor profile basename."
4015 - name: subscription_id
4016 description: "The Azure subscription identifier."
4017 - name: resource_uid
@@ -4128,7 +4217,7 @@ modules:
4217 - name: resource_type
4218 description: "The Azure resource type identifier."
4219 - name: profile
4131 - description: "The Azure Monitor profile id."
4220 + description: "The Azure Monitor profile basename."
4221 - name: subscription_id
4222 description: "The Azure subscription identifier."
4223 - name: resource_uid
@@ -4259,7 +4348,7 @@ modules:
4348 - name: resource_type
4349 description: "The Azure resource type identifier."
4350 - name: profile
4262 - description: "The Azure Monitor profile id."
4351 + description: "The Azure Monitor profile basename."
4352 - name: subscription_id
4353 description: "The Azure subscription identifier."
4354 - name: resource_uid
@@ -4417,7 +4506,7 @@ modules:
4506 - name: resource_type
4507 description: "The Azure resource type identifier."
4508 - name: profile
4420 - description: "The Azure Monitor profile id."
4509 + description: "The Azure Monitor profile basename."
4510 - name: subscription_id
4511 description: "The Azure subscription identifier."
4512 - name: resource_uid
@@ -4668,7 +4757,7 @@ modules:
4757 - name: resource_type
4758 description: "The Azure resource type identifier."
4759 - name: profile
4671 - description: "The Azure Monitor profile id."
4760 + description: "The Azure Monitor profile basename."
4761 - name: subscription_id
4762 description: "The Azure subscription identifier."
4763 - name: resource_uid
@@ -5030,7 +5119,7 @@ modules:
5119 - name: resource_type
5120 description: "The Azure resource type identifier."
5121 - name: profile
5033 - description: "The Azure Monitor profile id."
5122 + description: "The Azure Monitor profile basename."
5123 - name: subscription_id
5124 description: "The Azure subscription identifier."
5125 - name: resource_uid
@@ -5275,7 +5364,7 @@ modules:
5364 - name: resource_type
5365 description: "The Azure resource type identifier."
5366 - name: profile
5278 - description: "The Azure Monitor profile id."
5367 + description: "The Azure Monitor profile basename."
5368 - name: subscription_id
5369 description: "The Azure subscription identifier."
5370 - name: resource_uid
@@ -5601,7 +5690,7 @@ modules:
5690 - name: resource_type
5691 description: "The Azure resource type identifier."
5692 - name: profile
5604 - description: "The Azure Monitor profile id."
5693 + description: "The Azure Monitor profile basename."
5694 - name: subscription_id
5695 description: "The Azure subscription identifier."
5696 - name: resource_uid
@@ -5820,7 +5909,7 @@ modules:
5909 - name: resource_type
5910 description: "The Azure resource type identifier."
5911 - name: profile
5823 - description: "The Azure Monitor profile id."
5912 + description: "The Azure Monitor profile basename."
5913 - name: subscription_id
5914 description: "The Azure subscription identifier."
5915 - name: resource_uid
@@ -5999,7 +6088,7 @@ modules:
6088 - name: resource_type
6089 description: "The Azure resource type identifier."
6090 - name: profile
6002 - description: "The Azure Monitor profile id."
6091 + description: "The Azure Monitor profile basename."
6092 - name: subscription_id
6093 description: "The Azure subscription identifier."
6094 - name: resource_uid
@@ -6620,7 +6709,7 @@ modules:
6709 - name: resource_type
6710 description: "The Azure resource type identifier."
6711 - name: profile
6623 - description: "The Azure Monitor profile id."
6712 + description: "The Azure Monitor profile basename."
6713 - name: subscription_id
6714 description: "The Azure subscription identifier."
6715 - name: resource_uid
@@ -6948,7 +7037,7 @@ modules:
7037 - name: resource_type
7038 description: "The Azure resource type identifier."
7039 - name: profile
6951 - description: "The Azure Monitor profile id."
7040 + description: "The Azure Monitor profile basename."
7041 - name: subscription_id
7042 description: "The Azure subscription identifier."
7043 - name: resource_uid
@@ -7122,7 +7211,7 @@ modules:
7211 - name: resource_type
7212 description: "The Azure resource type identifier."
7213 - name: profile
7125 - description: "The Azure Monitor profile id."
7214 + description: "The Azure Monitor profile basename."
7215 - name: subscription_id
7216 description: "The Azure subscription identifier."
7217 - name: resource_uid
@@ -7552,7 +7641,7 @@ modules:
7641 - name: resource_type
7642 description: "The Azure resource type identifier."
7643 - name: profile
7555 - description: "The Azure Monitor profile id."
7644 + description: "The Azure Monitor profile basename."
7645 - name: subscription_id
7646 description: "The Azure subscription identifier."
7647 - name: resource_uid
@@ -7960,7 +8049,7 @@ modules:
8049 - name: resource_type
8050 description: "The Azure resource type identifier."
8051 - name: profile
7963 - description: "The Azure Monitor profile id."
8052 + description: "The Azure Monitor profile basename."
8053 - name: subscription_id
8054 description: "The Azure subscription identifier."
8055 - name: resource_uid
@@ -8070,7 +8159,7 @@ modules:
8159 - name: resource_type
8160 description: "The Azure resource type identifier."
8161 - name: profile
8073 - description: "The Azure Monitor profile id."
8162 + description: "The Azure Monitor profile basename."
8163 - name: subscription_id
8164 description: "The Azure subscription identifier."
8165 - name: resource_uid
@@ -8158,7 +8247,7 @@ modules:
8247 - name: resource_type
8248 description: "The Azure resource type identifier."
8249 - name: profile
8161 - description: "The Azure Monitor profile id."
8250 + description: "The Azure Monitor profile basename."
8251 - name: subscription_id
8252 description: "The Azure subscription identifier."
8253 - name: resource_uid
@@ -8310,7 +8399,7 @@ modules:
8399 - name: resource_type
8400 description: "The Azure resource type identifier."
8401 - name: profile
8313 - description: "The Azure Monitor profile id."
8402 + description: "The Azure Monitor profile basename."
8403 - name: subscription_id
8404 description: "The Azure subscription identifier."
8405 - name: resource_uid
@@ -8529,7 +8618,7 @@ modules:
8618 - name: resource_type
8619 description: "The Azure resource type identifier."
8620 - name: profile
8532 - description: "The Azure Monitor profile id."
8621 + description: "The Azure Monitor profile basename."
8622 - name: subscription_id
8623 description: "The Azure subscription identifier."
8624 - name: resource_uid
@@ -8700,7 +8789,7 @@ modules:
8789 - name: resource_type
8790 description: "The Azure resource type identifier."
8791 - name: profile
8703 - description: "The Azure Monitor profile id."
8792 + description: "The Azure Monitor profile basename."
8793 - name: subscription_id
8794 description: "The Azure subscription identifier."
8795 - name: resource_uid
@@ -9065,7 +9154,7 @@ modules:
9154 - name: resource_type
9155 description: "The Azure resource type identifier."
9156 - name: profile
9068 - description: "The Azure Monitor profile id."
9157 + description: "The Azure Monitor profile basename."
9158 - name: subscription_id
9159 description: "The Azure subscription identifier."
9160 - name: resource_uid
@@ -9439,7 +9528,7 @@ modules:
9528 - name: resource_type
9529 description: "The Azure resource type identifier."
9530 - name: profile
9442 - description: "The Azure Monitor profile id."
9531 + description: "The Azure Monitor profile basename."
9532 - name: subscription_id
9533 description: "The Azure subscription identifier."
9534 - name: resource_uid
@@ -9599,7 +9688,7 @@ modules:
9688 - name: resource_type
9689 description: "The Azure resource type identifier."
9690 - name: profile
9602 - description: "The Azure Monitor profile id."
9691 + description: "The Azure Monitor profile basename."
9692 - name: subscription_id
9693 description: "The Azure subscription identifier."
9694 - name: resource_uid
@@ -9829,7 +9918,7 @@ modules:
9918 - name: resource_type
9919 description: "The Azure resource type identifier."
9920 - name: profile
9832 - description: "The Azure Monitor profile id."
9921 + description: "The Azure Monitor profile basename."
9922 - name: subscription_id
9923 description: "The Azure subscription identifier."
9924 - name: resource_uid
@@ -10167,7 +10256,7 @@ modules:
10256 - name: resource_type
10257 description: "The Azure resource type identifier."
10258 - name: profile
10170 - description: "The Azure Monitor profile id."
10259 + description: "The Azure Monitor profile basename."
10260 - name: subscription_id
10261 description: "The Azure subscription identifier."
10262 - name: resource_uid
src/go/plugin/go.d/collector/azure_monitor/observation_state.go
+16 -13
@@ -93,29 +93,28 @@ func (s *observationState) reobserveCachedObservations(dueInstruments, observedT
93 }
94 }
95
96 -// pruneStaleResources removes cache entries for resources no longer in the discovery set.
97 -func (s *observationState) pruneStaleResources(current []resourceInfo) {
98 - activeUIDs := make(map[string]struct{}, len(current))
99 - for _, r := range current {
100 - activeUIDs[r.UID] = struct{}{}
96 +// pruneStaleResources removes cache entries for resources that are no longer
97 +// active for a specific profile/label identity.
98 +func (s *observationState) pruneStaleResources(current map[string][]resourceInfo) {
99 + activeLabels := make(map[string]struct{})
100 + for profileName, resources := range current {
101 + for _, resource := range resources {
102 + activeLabels[labelIdentity(labelValues(resourceLabels(resource, profileName)))] = struct{}{}
103 + }
104 }
105
106 for key, obs := range s.lastObserved {
107 if len(obs.labelValues) == 0 {
108 continue
109 }
107 - if _, ok := activeUIDs[obs.labelValues[0]]; ok {
110 + if _, ok := activeLabels[labelIdentity(obs.labelValues)]; ok {
111 continue
112 }
113 delete(s.lastObserved, key)
114 }
115
116 for key := range s.accumulators {
114 - uid := accumulatorResourceUID(key)
115 - if uid == "" {
116 - continue
117 - }
118 - if _, ok := activeUIDs[uid]; ok {
117 + if _, ok := activeLabels[labelIdentityFromObservationKey(key)]; ok {
118 continue
119 }
120 delete(s.accumulators, key)
@@ -126,8 +125,12 @@ func sampleObservationKey(instrument string, values []string) string {
125 return instrument + "\x00" + strings.Join(values, "\x00")
126 }
127
129 -func accumulatorResourceUID(key string) string {
130 - parts := strings.SplitN(key, "\x00", 3)
128 +func labelIdentity(values []string) string {
129 + return strings.Join(values, "\x00")
130 +}
131 +
132 +func labelIdentityFromObservationKey(key string) string {
133 + parts := strings.SplitN(key, "\x00", 2)
134 if len(parts) < 2 {
135 return ""
136 }
src/go/plugin/go.d/collector/azure_monitor/plan.go
+24 -23
@@ -12,8 +12,8 @@ import (
12 "gopkg.in/yaml.v3"
13 )
14
15 -func buildCollectorRuntimeFromConfig(profileIDs []string, catalog azureprofiles.Catalog) (*collectorRuntime, error) {
16 - profiles, err := catalog.Resolve(profileIDs)
15 +func buildCollectorRuntimeFromConfig(profileNames []string, profileEntries map[string]ProfileEntryConfig, catalog azureprofiles.Catalog) (*collectorRuntime, error) {
16 + profiles, err := catalog.Resolve(profileNames)
17 if err != nil {
18 return nil, err
19 }
@@ -22,18 +22,18 @@ func buildCollectorRuntimeFromConfig(profileIDs []string, catalog azureprofiles.
22 Profiles: make([]*profileRuntime, 0, len(profiles)),
23 }
24
25 - seenProfileIDs := make(map[string]struct{}, len(profiles))
25 + seenProfileNames := make(map[string]struct{}, len(profiles))
26 seenChartIDs := make(map[string]struct{})
27
28 for _, src := range profiles {
29 - p, err := buildProfileRuntime(src)
29 + p, err := buildProfileRuntime(src, profileEntries[src.Name])
30 if err != nil {
31 return nil, err
32 }
33 - if _, ok := seenProfileIDs[p.ID]; ok {
34 - return nil, fmt.Errorf("profile id collision for profile %q", src.ID)
33 + if _, ok := seenProfileNames[p.Name]; ok {
34 + return nil, fmt.Errorf("profile name collision for profile %q", src.Name)
35 }
36 - seenProfileIDs[p.ID] = struct{}{}
36 + seenProfileNames[p.Name] = struct{}{}
37
38 if err := walkCharts(p.Template, func(chart charttpl.Chart) error {
39 if _, ok := seenChartIDs[chart.ID]; ok {
@@ -57,39 +57,40 @@ func buildCollectorRuntimeFromConfig(profileIDs []string, catalog azureprofiles.
57 return runtime, nil
58 }
59
60 -func buildProfileRuntime(p azureprofiles.Profile) (*profileRuntime, error) {
61 - profileID := stringsTrim(p.ID)
62 - if profileID == "" {
63 - return nil, fmt.Errorf("profile has empty id")
60 +func buildProfileRuntime(resolved azureprofiles.ResolvedProfile, entry ProfileEntryConfig) (*profileRuntime, error) {
61 + profileName := stringsTrim(resolved.Name)
62 + if profileName == "" {
63 + return nil, fmt.Errorf("profile has empty name")
64 }
65
66 - name := stringsTrim(p.DisplayName)
67 - if name == "" {
68 - return nil, fmt.Errorf("profile %q has empty name", profileID)
66 + displayName := stringsTrim(resolved.Config.DisplayName)
67 + if displayName == "" {
68 + return nil, fmt.Errorf("profile %q has empty display_name", profileName)
69 }
70
71 - resourceType := stringsTrim(p.ResourceType)
72 - metricNamespace := stringsTrim(p.MetricNamespace)
71 + resourceType := stringsTrim(resolved.Config.ResourceType)
72 + metricNamespace := stringsTrim(resolved.Config.MetricNamespace)
73 if metricNamespace == "" {
74 metricNamespace = resourceType
75 }
76
77 out := &profileRuntime{
78 - ID: profileID,
79 - Name: name,
78 + Name: profileName,
79 + DisplayName: displayName,
80 ResourceType: resourceType,
81 MetricNamespace: metricNamespace,
82 - Metrics: make([]*metricRuntime, 0, len(p.Metrics)),
82 + Filters: cloneResourceFilters(entry.Filters),
83 + Metrics: make([]*metricRuntime, 0, len(resolved.Config.Metrics)),
84 }
85
85 - for _, m := range p.Metrics {
86 + for _, m := range resolved.Config.Metrics {
87 grain := strings.ToUpper(stringsTrim(m.TimeGrain))
88 if grain == "" {
89 grain = "PT1M"
90 }
91 grainEvery, ok := azureprofiles.SupportedTimeGrains[grain]
92 if !ok {
92 - return nil, fmt.Errorf("profile %q metric %q has unsupported time grain %q", profileID, m.ID, grain)
93 + return nil, fmt.Errorf("profile %q metric %q has unsupported time grain %q", profileName, m.ID, grain)
94 }
95
96 mr := &metricRuntime{
@@ -105,7 +106,7 @@ func buildProfileRuntime(p azureprofiles.Profile) (*profileRuntime, error) {
106 mr.Series = append(mr.Series, &seriesRuntime{
107 Aggregation: aggregation,
108 Kind: azureprofiles.NormalizeSeriesKind(s.Kind),
108 - Instrument: azureprofiles.ExportedSeriesName(profileID, m.ID, aggregation),
109 + Instrument: azureprofiles.ExportedSeriesName(profileName, m.ID, aggregation),
110 })
111 }
112
@@ -119,7 +120,7 @@ func buildProfileRuntime(p azureprofiles.Profile) (*profileRuntime, error) {
120 return out.Metrics[i].ID < out.Metrics[j].ID
121 })
122
122 - out.Template = p.Template
123 + out.Template = resolved.Config.Template
124 out.Template.Metrics = profileMetricsList(out)
125 return out, nil
126 }
src/go/plugin/go.d/collector/azure_monitor/profile_catalog_test.go
+80 -30
@@ -40,8 +40,7 @@ func TestLoadProfileCatalogFromDirs_UserOverridesStock(t *testing.T) {
40 stockDir := filepath.Join(dir, "stock")
41
42 require.NoError(t, writeProfileFile(filepath.Join(userDir, "sql_database.yaml"), `
43 -id: sql_database
44 -name: Azure SQL Database (User Override)
43 +display_name: Azure SQL Database (User Override)
44 resource_type: Microsoft.Sql/servers/databases
45 metrics:
46 - id: cpu_percent
@@ -69,8 +68,7 @@ template:
68 name: average
69 `))
70 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "sql_database.yaml"), `
72 -id: sql_database
73 -name: Azure SQL Database (Stock)
71 +display_name: Azure SQL Database (Stock)
72 resource_type: Microsoft.Sql/servers/databases
73 metrics:
74 - id: cpu_percent
@@ -98,8 +96,7 @@ template:
96 name: average
97 `))
98 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "postgres_flexible.yaml"), `
101 -id: postgres_flexible
102 -name: Azure PostgreSQL Flexible Server
99 +display_name: Azure PostgreSQL Flexible Server
100 resource_type: Microsoft.DBforPostgreSQL/flexibleServers
101 metrics:
102 - id: cpu_percent
@@ -133,7 +130,7 @@ template:
130 })
131 require.NoError(t, err)
132
136 - gotProfiles, err := catalog.ResolveBaseNames([]string{"SQL_DATABASE"})
133 + gotProfiles, err := catalog.ResolveBaseNames([]string{"sql_database"})
134 require.NoError(t, err)
135 require.Len(t, gotProfiles, 1)
136 got := gotProfiles[0]
@@ -145,8 +142,7 @@ func TestLoadProfileCatalogFromDirs_RejectsDuplicateProfileBasenames(t *testing.
142 stockDir := filepath.Join(dir, "stock")
143
144 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "sql_database.yaml"), `
148 -id: sql_database
149 -name: Azure SQL Database
145 +display_name: Azure SQL Database
146 resource_type: Microsoft.Sql/servers/databases
147 metrics:
148 - id: cpu_percent
@@ -174,8 +170,7 @@ template:
170 name: average
171 `))
172 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "nested", "sql_database.yml"), `
177 -id: sql_database_copy
178 -name: azure sql database
173 +display_name: azure sql database
174 resource_type: Microsoft.Sql/servers/databases
175 metrics:
176 - id: cpu_percent
@@ -199,7 +194,7 @@ template:
194 instances:
195 by_labels: [resource_uid]
196 dimensions:
202 - - selector: sql_database_copy.cpu_percent_average
197 + - selector: sql_database.cpu_percent_average
198 name: average
199 `))
200
@@ -211,13 +206,53 @@ template:
206 assert.Contains(t, err.Error(), `"sql_database"`)
207 }
208
214 -func TestLoadProfileCatalogFromDirs_RejectsDuplicateProfileIDsAcrossBasenames(t *testing.T) {
209 +func TestLoadProfileCatalogFromDirs_RejectsInvalidProfileBasename(t *testing.T) {
210 + dir := t.TempDir()
211 + stockDir := filepath.Join(dir, "stock")
212 +
213 + require.NoError(t, writeProfileFile(filepath.Join(stockDir, "SQL_DATABASE.yaml"), `
214 +display_name: Azure SQL Database Copy
215 +resource_type: Microsoft.Sql/servers/databases
216 +metrics:
217 + - id: cpu_percent
218 + azure_name: cpu_percent
219 + time_grain: PT1M
220 + series:
221 + - aggregation: average
222 + kind: gauge
223 +template:
224 + family: Azure SQL Database Copy
225 + context_namespace: sql_database_copy
226 + charts:
227 + - id: am_test_sql_database_copy_cpu
228 + title: Azure SQL Database Copy CPU
229 + context: cpu_copy
230 + family: Utilization
231 + type: line
232 + units: percentage
233 + algorithm: absolute
234 + label_promotion: [resource_name, resource_group, region, resource_type, profile]
235 + instances:
236 + by_labels: [resource_uid]
237 + dimensions:
238 + - selector: sql_database.cpu_percent_average
239 + name: average
240 +`))
241 +
242 + _, err := azureprofiles.LoadFromDirs([]azureprofiles.DirSpec{
243 + {Path: stockDir, IsStock: true},
244 + })
245 + require.Error(t, err)
246 + assert.Contains(t, err.Error(), `basename must match`)
247 + assert.Contains(t, err.Error(), `SQL_DATABASE.yaml`)
248 +}
249 +
250 +func TestLoadProfileCatalogFromDirs_NormalizesLocalSelectorShorthand(t *testing.T) {
251 dir := t.TempDir()
252 stockDir := filepath.Join(dir, "stock")
253
254 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "sql_database.yaml"), `
219 -id: sql_database
220 -name: Azure SQL Database
255 +display_name: Azure SQL Database
256 resource_type: Microsoft.Sql/servers/databases
257 metrics:
258 - id: cpu_percent
@@ -241,12 +276,29 @@ template:
276 instances:
277 by_labels: [resource_uid]
278 dimensions:
244 - - selector: sql_database.cpu_percent_average
279 + - selector: cpu_percent_average
280 name: average
281 `))
247 - require.NoError(t, writeProfileFile(filepath.Join(stockDir, "sql_database_copy.yaml"), `
248 -id: sql_database
249 -name: Azure SQL Database Copy
282 +
283 + catalog, err := azureprofiles.LoadFromDirs([]azureprofiles.DirSpec{
284 + {Path: stockDir, IsStock: true},
285 + })
286 + require.NoError(t, err)
287 +
288 + profiles, err := catalog.ResolveBaseNames([]string{"sql_database"})
289 + require.NoError(t, err)
290 + require.Len(t, profiles, 1)
291 + require.Len(t, profiles[0].Template.Charts, 1)
292 + require.Len(t, profiles[0].Template.Charts[0].Dimensions, 1)
293 + assert.Equal(t, "sql_database.cpu_percent_average", profiles[0].Template.Charts[0].Dimensions[0].Selector)
294 +}
295 +
296 +func TestLoadProfileCatalogFromDirs_RejectsUnknownSelectorShorthand(t *testing.T) {
297 + dir := t.TempDir()
298 + stockDir := filepath.Join(dir, "stock")
299 +
300 + require.NoError(t, writeProfileFile(filepath.Join(stockDir, "sql_database.yaml"), `
301 +display_name: Azure SQL Database
302 resource_type: Microsoft.Sql/servers/databases
303 metrics:
304 - id: cpu_percent
@@ -256,12 +308,12 @@ metrics:
308 - aggregation: average
309 kind: gauge
310 template:
259 - family: Azure SQL Database Copy
260 - context_namespace: sql_database_copy
311 + family: Azure SQL Database
312 + context_namespace: sql_database
313 charts:
262 - - id: am_test_sql_database_copy_cpu
263 - title: Azure SQL Database Copy CPU
264 - context: cpu_copy
314 + - id: am_test_sql_database_cpu
315 + title: Azure SQL Database CPU
316 + context: cpu
317 family: Utilization
318 type: line
319 units: percentage
@@ -270,7 +322,7 @@ template:
322 instances:
323 by_labels: [resource_uid]
324 dimensions:
273 - - selector: sql_database.cpu_percent_average
325 + - selector: missing_average
326 name: average
327 `))
328
@@ -278,9 +330,8 @@ template:
330 {Path: stockDir, IsStock: true},
331 })
332 require.Error(t, err)
281 - assert.Contains(t, err.Error(), `duplicate profile id`)
282 - assert.Contains(t, err.Error(), `"sql_database"`)
283 - assert.Contains(t, err.Error(), `"sql_database_copy"`)
333 + assert.Contains(t, err.Error(), "template")
334 + assert.Contains(t, err.Error(), "missing_average")
335 }
336
337 func writeProfileFile(path, data string) error {
@@ -295,8 +346,7 @@ func TestLoadProfileCatalogFromDirs_RejectsLegacyProfileWithoutCharts(t *testing
346 stockDir := filepath.Join(dir, "stock")
347
348 require.NoError(t, writeProfileFile(filepath.Join(stockDir, "legacy.yaml"), `
298 -id: legacy
299 -name: Azure Legacy
349 +display_name: Azure Legacy
350 resource_type: Microsoft.Storage/storageAccounts
351 metrics:
352 - id: used_capacity
src/go/plugin/go.d/collector/azure_monitor/query_executor.go
+5 -5
@@ -103,7 +103,7 @@ func (e *queryExecutor) executeQueryBatch(ctx context.Context, batch queryBatch,
103 return nil, err
104 }
105
106 - return samplesFromQueryResponse(resp.Values, batch.Profile.ID, queryBatchMetricIndex(batch.Metrics), resourceByID), nil
106 + return samplesFromQueryResponse(resp.Values, batch.Profile.Name, queryBatchMetricIndex(batch.Metrics), resourceByID), nil
107 }
108
109 func queryBatchMetricIndex(metrics []*metricRuntime) map[string]*metricRuntime {
@@ -148,14 +148,14 @@ func effectiveQueryOffset(queryOffsetSeconds int, batchTimeGrainEvery time.Durat
148 return offset
149 }
150
151 -func samplesFromQueryResponse(metricData []azmetrics.MetricData, profileID string, metricToRuntime map[string]*metricRuntime, resourceByID map[string]resourceInfo) []metricSample {
151 +func samplesFromQueryResponse(metricData []azmetrics.MetricData, profileName string, metricToRuntime map[string]*metricRuntime, resourceByID map[string]resourceInfo) []metricSample {
152 samples := make([]metricSample, 0, len(metricData))
153 for _, data := range metricData {
154 resource, ok := resourceByID[stringsLowerTrim(derefOrZero(data.ResourceID))]
155 if !ok {
156 continue
157 }
158 - samples = append(samples, samplesFromMetricValues(data.Values, resourceLabels(resource, profileID), metricToRuntime)...)
158 + samples = append(samples, samplesFromMetricValues(data.Values, resourceLabels(resource, profileName), metricToRuntime)...)
159 }
160 return samples
161 }
@@ -183,7 +183,7 @@ func samplesFromMetricValues(metrics []azmetrics.Metric, labels metrix.Labels, m
183 return samples
184 }
185
186 -func resourceLabels(resource resourceInfo, profileID string) metrix.Labels {
186 +func resourceLabels(resource resourceInfo, profileName string) metrix.Labels {
187 return metrix.Labels{
188 "resource_uid": resource.UID,
189 "subscription_id": resource.SubscriptionID,
@@ -191,7 +191,7 @@ func resourceLabels(resource resourceInfo, profileID string) metrix.Labels {
191 "resource_group": resource.ResourceGroup,
192 "region": resource.Region,
193 "resource_type": resource.Type,
194 - "profile": profileID,
194 + "profile": profileName,
195 }
196 }
197
src/go/plugin/go.d/collector/azure_monitor/query_schedule.go
+2 -15
@@ -10,30 +10,17 @@ import (
10 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/azure_monitor/azureprofiles"
11 )
12
13 -func (c *Collector) buildQueryBatches(resources []resourceInfo, now time.Time) []queryBatch {
13 +func (c *Collector) buildQueryBatches(now time.Time) []queryBatch {
14 dueByGrain := make(map[string]bool)
15 - resourcesByType := c.indexResourcesByType(resources)
15 var batches []queryBatch
16
17 for _, profile := range c.runtime.Profiles {
19 - batches = append(batches, c.buildProfileQueryBatches(profile, resourcesByType[stringsLowerTrim(profile.ResourceType)], dueByGrain, now)...)
18 + batches = append(batches, c.buildProfileQueryBatches(profile, c.discovery.ByProfile[profile.Name], dueByGrain, now)...)
19 }
20
21 return batches
22 }
23
25 -func (c *Collector) indexResourcesByType(resources []resourceInfo) map[string][]resourceInfo {
26 - if len(c.discovery.ByType) > 0 {
27 - return c.discovery.ByType
28 - }
29 -
30 - result := make(map[string][]resourceInfo)
31 - for _, resource := range resources {
32 - result[stringsLowerTrim(resource.Type)] = append(result[stringsLowerTrim(resource.Type)], resource)
33 - }
34 - return result
35 -}
36 -
24 func (c *Collector) buildProfileQueryBatches(profile *profileRuntime, resources []resourceInfo, dueByGrain map[string]bool, now time.Time) []queryBatch {
25 if len(resources) == 0 {
26 return nil
src/go/plugin/go.d/collector/azure_monitor/testdata/config.json
+4 -2
@@ -16,8 +16,10 @@
16 "profiles": {
17 "mode": "exact",
18 "mode_exact": {
19 - "names": [
20 - "sql_managed_instance"
19 + "entries": [
20 + {
21 + "name": "sql_managed_instance"
22 + }
23 ]
24 }
25 },
src/go/plugin/go.d/collector/azure_monitor/testdata/config.yaml
+2 -2
@@ -11,8 +11,8 @@ discovery:
11 profiles:
12 mode: exact
13 mode_exact:
14 - names:
15 - - sql_managed_instance
14 + entries:
15 + - name: sql_managed_instance
16 query_offset: 180
17 timeout: 30
18 limits:
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/aks.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: aks
3 -name: Azure Kubernetes Service Cluster
2 +display_name: Azure Kubernetes Service Cluster
3 resource_type: Microsoft.ContainerService/managedClusters
4 metrics:
5 - id: apiserver_cpu_usage_percentage
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/api_management.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: api_management
3 -name: Azure API Management
2 +display_name: Azure API Management
3 resource_type: Microsoft.ApiManagement/service
4 metrics:
5 - id: capacity
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/app_service.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: app_service
3 -name: Azure App Service
2 +display_name: Azure App Service
3 resource_type: Microsoft.Web/sites
4 metrics:
5 - id: requests
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/application_gateway.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: application_gateway
3 -name: Azure Application Gateway
2 +display_name: Azure Application Gateway
3 resource_type: Microsoft.Network/applicationGateways
4 metrics:
5 - id: throughput
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/application_insights.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: application_insights
3 -name: Azure Application Insights
2 +display_name: Azure Application Insights
3 resource_type: Microsoft.Insights/components
4 metrics:
5 - id: availability_results_availability_percentage
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/cognitive_services.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: cognitive_services
3 -name: Azure Cognitive Services
2 +display_name: Azure Cognitive Services
3 resource_type: Microsoft.CognitiveServices/accounts
4 metrics:
5 - id: success_rate
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/container_apps.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: container_apps
3 -name: Azure Container Apps
2 +display_name: Azure Container Apps
3 resource_type: Microsoft.App/containerApps
4 metrics:
5 - id: usage_nano_cores
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/container_instances.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: container_instances
3 -name: Azure Container Instances
2 +display_name: Azure Container Instances
3 resource_type: Microsoft.ContainerInstance/containerGroups
4 metrics:
5 - id: cpu_usage
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/container_registry.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: container_registry
3 -name: Azure Container Registry
2 +display_name: Azure Container Registry
3 resource_type: Microsoft.ContainerRegistry/registries
4 metrics:
5 - id: storage_used
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/cosmos_db.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: cosmos_db
3 -name: Azure Cosmos DB Account
2 +display_name: Azure Cosmos DB Account
3 resource_type: Microsoft.DocumentDB/databaseAccounts
4 metrics:
5 - id: total_requests
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/data_explorer.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: data_explorer
3 -name: Azure Data Explorer Cluster
2 +display_name: Azure Data Explorer Cluster
3 resource_type: Microsoft.Kusto/clusters
4 metrics:
5 - id: cpu
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/data_factory.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: data_factory
3 -name: Azure Data Factory
2 +display_name: Azure Data Factory
3 resource_type: Microsoft.DataFactory/factories
4 metrics:
5 - id: pipeline_succeeded_runs
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/event_grid.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: event_grid
3 -name: Azure Event Grid Topic
2 +display_name: Azure Event Grid Topic
3 resource_type: Microsoft.EventGrid/topics
4 metrics:
5 - id: publish_success_count
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/event_hubs.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: event_hubs
3 -name: Azure Event Hubs Namespace
2 +display_name: Azure Event Hubs Namespace
3 resource_type: Microsoft.EventHub/Namespaces
4 metrics:
5 - id: incoming_messages
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/express_route_circuit.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: express_route_circuit
3 -name: Azure ExpressRoute Circuit
2 +display_name: Azure ExpressRoute Circuit
3 resource_type: Microsoft.Network/expressRouteCircuits
4 metrics:
5 - id: arp_availability
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/express_route_gateway.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: express_route_gateway
3 -name: Azure ExpressRoute Gateway
2 +display_name: Azure ExpressRoute Gateway
3 resource_type: Microsoft.Network/expressRouteGateways
4 metrics:
5 - id: er_gateway_connection_bits_in_per_second
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/firewall.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: firewall
3 -name: Azure Firewall
2 +display_name: Azure Firewall
3 resource_type: Microsoft.Network/azureFirewalls
4 metrics:
5 - id: firewall_health
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/front_door.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: front_door
3 -name: Azure Front Door
2 +display_name: Azure Front Door
3 resource_type: Microsoft.Cdn/profiles
4 metrics:
5 - id: total_latency
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/iot_hub.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: iot_hub
3 -name: Azure IoT Hub
2 +display_name: Azure IoT Hub
3 resource_type: Microsoft.Devices/IotHubs
4 metrics:
5 - id: c2d_commands_egress_abandon_success
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/key_vault.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: key_vault
3 -name: Azure Key Vault
2 +display_name: Azure Key Vault
3 resource_type: Microsoft.KeyVault/vaults
4 metrics:
5 - id: availability
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/load_balancers.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: load_balancers
3 -name: Azure Load Balancer
2 +display_name: Azure Load Balancer
3 resource_type: Microsoft.Network/loadBalancers
4 metrics:
5 - id: vip_availability
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/log_analytics.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: log_analytics
3 -name: Azure Log Analytics Workspace
2 +display_name: Azure Log Analytics Workspace
3 resource_type: Microsoft.OperationalInsights/workspaces
4 metrics:
5 - id: availability_rate_query
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/logic_apps.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: logic_apps
3 -name: Azure Logic Apps Workflow
2 +display_name: Azure Logic Apps Workflow
3 resource_type: Microsoft.Logic/workflows
4 metrics:
5 - id: runs_started
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/machine_learning.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: machine_learning
3 -name: Azure Machine Learning Workspace
2 +display_name: Azure Machine Learning Workspace
3 resource_type: Microsoft.MachineLearningServices/workspaces
4 metrics:
5 - id: agents
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/mysql_flexible.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: mysql_flexible
3 -name: Azure MySQL Flexible Server
2 +display_name: Azure MySQL Flexible Server
3 resource_type: Microsoft.DBforMySQL/flexibleServers
4 metrics:
5 - id: ha_io_status
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/nat_gateway.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: nat_gateway
3 -name: Azure NAT Gateway
2 +display_name: Azure NAT Gateway
3 resource_type: Microsoft.Network/natGateways
4 metrics:
5 - id: datapath_availability
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/postgres_flexible.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: postgres_flexible
3 -name: Azure PostgreSQL Flexible Server
2 +display_name: Azure PostgreSQL Flexible Server
3 resource_type: Microsoft.DBforPostgreSQL/flexibleServers
4 metrics:
5 - id: cpu_percent
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/redis_cache.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: redis_cache
3 -name: Azure Cache for Redis
2 +display_name: Azure Cache for Redis
3 resource_type: Microsoft.Cache/redis
4 metrics:
5 - id: cachehits
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/service_bus.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: service_bus
3 -name: Azure Service Bus Namespace
2 +display_name: Azure Service Bus Namespace
3 resource_type: Microsoft.ServiceBus/Namespaces
4 metrics:
5 - id: incoming_messages
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/sql_database.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: sql_database
3 -name: Azure SQL Database
2 +display_name: Azure SQL Database
3 resource_type: Microsoft.Sql/servers/databases
4 metrics:
5 - id: cpu_percent
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/sql_elastic_pool.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: sql_elastic_pool
3 -name: Azure SQL Elastic Pool
2 +display_name: Azure SQL Elastic Pool
3 resource_type: Microsoft.Sql/servers/elasticPools
4 metrics:
5 - id: cpu_percent
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/sql_managed_instance.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: sql_managed_instance
3 -name: Azure SQL Managed Instance
2 +display_name: Azure SQL Managed Instance
3 resource_type: Microsoft.Sql/managedInstances
4 metrics:
5 - id: avg_cpu_percent
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/storage_accounts.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: storage_accounts
3 -name: Azure Storage Account
2 +display_name: Azure Storage Account
3 resource_type: Microsoft.Storage/storageAccounts
4 metrics:
5 - id: availability
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/stream_analytics.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: stream_analytics
3 -name: Azure Stream Analytics Job
2 +display_name: Azure Stream Analytics Job
3 resource_type: Microsoft.StreamAnalytics/streamingjobs
4 metrics:
5 - id: input_events
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/synapse.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: synapse
3 -name: Azure Synapse Analytics Workspace
2 +display_name: Azure Synapse Analytics Workspace
3 resource_type: Microsoft.Synapse/workspaces
4 metrics:
5 - id: builtin_sql_pool_data_processed_bytes
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/virtual_machines.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: virtual_machines
3 -name: Azure Virtual Machine
2 +display_name: Azure Virtual Machine
3 resource_type: Microsoft.Compute/virtualMachines
4 metrics:
5 - id: percentage_cpu
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/vmss.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: vmss
3 -name: Azure Virtual Machine Scale Set
2 +display_name: Azure Virtual Machine Scale Set
3 resource_type: Microsoft.Compute/virtualMachineScaleSets
4 metrics:
5 - id: percentage_cpu
src/go/plugin/go.d/config/go.d/azure_monitor.profiles/default/vpn_gateway.yaml
+1 -2
@@ -1,6 +1,5 @@
1 ---
2 -id: vpn_gateway
3 -name: Azure VPN Gateway
2 +display_name: Azure VPN Gateway
3 resource_type: Microsoft.Network/virtualNetworkGateways
4 metrics:
5 - id: average_bandwidth