feat(go.d): add vnode-scoped metrics for Azure Monitor workloads (#22402)
Ilya Mashchenko committed
May 4, 2026 at 11:18 UTC
151d78c63cc350a94aedc1d9b2dea58215671adb
99 files changed
+10449
-879
.agents/skills/project-writing-collectors/SKILL.md
+2
@@ -72,6 +72,8 @@ The repo holds 132 go.d modules and 24 internal C plugins. Maintainer patterns l
72
73
When one collector talks to N targets (SNMP devices, remote DBs, cloud APIs, IPMI hosts, vCenter clusters), each target is a **vnode** so its metrics, alerts, and RBAC behave as if it were a separate node in Netdata Cloud. Every remote-target collector wires vnodes from the start.
74
75
+For Go v2 collectors that route one job's samples to multiple virtual nodes, use first-class `metrix.HostScope` rather than adding vnode identity as normal metric labels. Write per-resource metrics through scoped meters or vecs such as `meter.WithHostScope(scope)`, and leave metrics unscoped when they should follow the default job vnode or global host path. Scope keys must be stable for the virtual node identity; unbounded scope cardinality has the same operational cost profile as unbounded chart/cardinality growth.
76
+
77
### 1.10 Cardinality discipline
78
79
- A chart with thousands of dimensions, or an instance list with thousands of entries, is unusable on the dashboard. The user cannot read it.
.agents/sow/specs/go-v2-host-scope.md
new
+86
@@ -0,0 +1,86 @@
1
+# Go V2 Host Scope And Virtual Node Emission
2
+
3
+## Scope
4
+
5
+This spec records the framework contract for Go collector v2 host-scope routing.
6
+It applies to `pkg/metrix`, `plugin/framework/jobruntime`,
7
+`plugin/framework/chartengine`, and v2 collectors that emit metrics for remote
8
+or virtual-node targets.
9
+
10
+## Metrix Host Scope
11
+
12
+- `metrix.HostScope{}` is the default host scope.
13
+- Unscoped writes are equivalent to writes in the default scope.
14
+- A non-default host scope carries:
15
+ - `ScopeKey`: stable scope partition key;
16
+ - `GUID`: Netdata host/vnode GUID;
17
+ - `Hostname`: Netdata host/vnode hostname when defining the host;
18
+ - `Labels`: deterministic host/vnode labels.
19
+- Series identity includes host scope. The same metric name and labels can exist
20
+ in default scope and multiple non-default scopes without collision.
21
+- `Read()` without `ReadHostScope` returns default-scope series only.
22
+- `Read(ReadHostScope(key))` returns only that scope's series.
23
+- `Reader.HostScopes()` enumerates all scopes present in the snapshot and is not
24
+ filtered by the reader's active host scope.
25
+- Flattened synthetic series preserve the source host scope.
26
+- Scope metadata conflicts in one collect cycle are data errors surfaced through
27
+ `CommitCycleSuccess() error`, not panics.
28
+
29
+## Jobruntime V2
30
+
31
+- V2 jobruntime owns host/vnode orchestration. Chartengine remains host-agnostic.
32
+- One `chartengine.Engine` is used per host scope for a job.
33
+- Scope engines are created lazily.
34
+- Default-scope metrics continue to emit under the job-level vnode when one is
35
+ configured, otherwise under the global host.
36
+- Explicit non-default scopes emit under their `metrix.HostScope` GUID and host
37
+ metadata.
38
+- Collection and `metrix.CommitCycleSuccess()` are still all-or-nothing.
39
+- Post-collect plan/apply/commit is per-scope partial success. A failed scope
40
+ rolls back its own registry changes and does not block unrelated scopes.
41
+- Disappeared scopes are retained and read with empty scoped readers until the
42
+ per-scope chartengine emits lifecycle removals. After successful removal
43
+ emission, jobruntime releases scoped registry owners and destroys the scope
44
+ engine.
45
+- Job cleanup emits obsolete charts for each retained scope before releasing
46
+ registry owners.
47
+
48
+## Vnode Registry
49
+
50
+- V2 vnode definitions go through the shared `framework/vnoderegistry` registry.
51
+- Registry entries are keyed by host GUID and owner.
52
+- Metadata is update-on-change. A new normalized metadata value for an existing
53
+ GUID replaces retained metadata and causes another `HOST_DEFINE`.
54
+- Owner release removes an entry only after the last owner for that GUID leaves.
55
+- Job-level vnode owners and explicit scoped vnode owners use separate owner
56
+ namespaces.
57
+
58
+## Chartengine Runtime Metrics
59
+
60
+- Per-scope engines do not register per-scope runtime components.
61
+- Jobruntime feeds chartengine runtime samples into one job-level
62
+ `chartengine.RuntimeAggregator`.
63
+- Aggregated runtime metrics do not include host-scope/workload labels.
64
+- Counter-like runtime metrics are summed across samples.
65
+- Gauge-like size metrics represent the latest successful build rollup, summed
66
+ across successful engines in that rollup.
67
+- `build_seq_violation_active` is `1` when any observed engine reports a
68
+ sequence violation in the rollup, otherwise `0`.
69
+
70
+## Collector Contract
71
+
72
+- V2 collectors that need per-target virtual nodes should write target metrics
73
+ through `meter.WithHostScope(scope)` or equivalent scoped vec/instrument
74
+ bindings.
75
+- V2 collectors should leave metrics unscoped when those metrics belong to the
76
+ default job vnode/global host.
77
+- Collector-generated scope keys must be deterministic and stable for the target
78
+ host/vnode identity.
79
+- Collector-generated host labels should include `_vnode_type=<source>` when a
80
+ collector creates virtual nodes from an internal mechanism rather than a
81
+ user-defined vnode entry. The value must identify the mechanism or source
82
+ without embedding high-cardinality target values.
83
+- Azure Monitor resource-tag virtual nodes use `_vnode_type=azure_workload`.
84
+ Their GUID and `ScopeKey` are the deterministic SHA1 UUID of
85
+ `azure_monitor:` plus the trimmed, case-preserved tag value.
86
+- Collectors are responsible for bounding or documenting scope cardinality risk.
src/go/pkg/metrix/backend.go
+15
-15
@@ -8,21 +8,21 @@ package metrix
8
type meterBackend interface {
9
compileLabelSet(labels ...Label) LabelSet
10
registerInstrument(name string, kind metricKind, mode metricMode, opts ...InstrumentOption) (*instrumentDescriptor, error)
11
- recordGaugeSet(desc *instrumentDescriptor, value SampleValue, sets []LabelSet)
12
- recordGaugeAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet)
13
- recordCounterObserveTotal(desc *instrumentDescriptor, value SampleValue, sets []LabelSet)
14
- recordCounterAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet)
15
- recordHistogramObservePoint(desc *instrumentDescriptor, point HistogramPoint, sets []LabelSet)
16
- recordHistogramObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet)
17
- recordSummaryObservePoint(desc *instrumentDescriptor, point SummaryPoint, sets []LabelSet)
18
- recordSummaryObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet)
19
- recordStateSetObserve(desc *instrumentDescriptor, point StateSetPoint, sets []LabelSet)
20
- recordMeasureSetGaugeObservePoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet)
21
- recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet)
22
- recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet)
23
- recordMeasureSetGaugeSetField(desc *instrumentDescriptor, field string, value SampleValue, sets []LabelSet)
24
- recordMeasureSetCounterObserveTotalPoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet)
25
- recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet)
11
+ recordGaugeSet(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet)
12
+ recordGaugeAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet)
13
+ recordCounterObserveTotal(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet)
14
+ recordCounterAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet)
15
+ recordHistogramObservePoint(desc *instrumentDescriptor, scope HostScope, point HistogramPoint, sets []LabelSet)
16
+ recordHistogramObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet)
17
+ recordSummaryObservePoint(desc *instrumentDescriptor, scope HostScope, point SummaryPoint, sets []LabelSet)
18
+ recordSummaryObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet)
19
+ recordStateSetObserve(desc *instrumentDescriptor, scope HostScope, point StateSetPoint, sets []LabelSet)
20
+ recordMeasureSetGaugeObservePoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet)
21
+ recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet)
22
+ recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet)
23
+ recordMeasureSetGaugeSetField(desc *instrumentDescriptor, scope HostScope, field string, value SampleValue, sets []LabelSet)
24
+ recordMeasureSetCounterObserveTotalPoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet)
25
+ recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet)
26
}
27
28
var _ meterBackend = (*storeCore)(nil)
src/go/pkg/metrix/collector_store.go
+106
-24
@@ -3,6 +3,7 @@
3
package metrix
4
5
import (
6
+ "errors"
7
"fmt"
8
"maps"
9
"math"
@@ -69,6 +70,9 @@ type committedSeries struct {
70
hash64 uint64
71
key string
72
name string
73
+ // hostScope is immutable after publish and partitions otherwise-identical series.
74
+ hostScopeKey string
75
+ hostScope HostScope
76
// labels are immutable after series publish and can be safely shared across snapshots.
77
labels []Label
78
labelsKey string
@@ -122,6 +126,8 @@ type readSnapshot struct {
126
127
type cycleFrame struct {
128
seq uint64
129
+ err error
130
+ hostScopes map[string]HostScope
131
gauges map[string]*stagedGauge
132
counters map[string]*stagedCounter
133
histograms map[string]*stagedHistogram
@@ -205,7 +211,7 @@ func (s *storeView) Read(opts ...ReadOption) Reader {
211
if cfg.flatten {
212
snap = flattenSnapshot(snap)
213
}
208
- return &storeReader{snap: snap, raw: cfg.raw, flattened: cfg.flatten}
214
+ return &storeReader{snap: snap, raw: cfg.raw, flattened: cfg.flatten, hostScopeKey: cfg.hostScopeKey}
215
}
216
217
func (s *storeView) Write() Writer {
@@ -236,6 +242,7 @@ func (c *storeCycleController) BeginCycle() {
242
c.core.sequence++
243
c.core.active = &cycleFrame{
244
seq: c.core.sequence,
245
+ hostScopes: make(map[string]HostScope),
246
gauges: make(map[string]*stagedGauge),
247
counters: make(map[string]*stagedCounter),
248
histograms: make(map[string]*stagedHistogram),
@@ -247,7 +254,7 @@ func (c *storeCycleController) BeginCycle() {
254
}
255
256
// CommitCycleSuccess publishes staged writes into a new committed snapshot.
250
-func (c *storeCycleController) CommitCycleSuccess() {
257
+func (c *storeCycleController) CommitCycleSuccess() error {
258
c.core.mu.Lock()
259
defer c.core.mu.Unlock()
260
@@ -256,23 +263,39 @@ func (c *storeCycleController) CommitCycleSuccess() {
263
}
264
265
oldSnap := c.core.snapshot.Load()
266
+ if c.core.active.err != nil {
267
+ abortSnap := &readSnapshot{
268
+ collectMeta: oldSnap.collectMeta,
269
+ series: oldSnap.series,
270
+ byName: oldSnap.byName,
271
+ }
272
+ abortSnap.collectMeta.LastAttemptSeq = c.core.active.seq
273
+ abortSnap.collectMeta.LastAttemptStatus = CollectStatusFailed
274
+ c.core.snapshot.Store(abortSnap)
275
+ err := c.core.active.err
276
+ c.core.active = nil
277
+ return err
278
+ }
279
successSeq := c.core.successSeq + 1
280
next := &readSnapshot{
281
collectMeta: oldSnap.collectMeta,
282
series: make(map[string]*committedSeries, len(oldSnap.series)),
283
byName: nil,
284
}
285
+ commitHostScopes := make(map[string]HostScope)
286
287
maps.Copy(next.series, oldSnap.series)
288
289
for key, staged := range c.core.active.gauges {
269
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
290
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
291
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
292
series.value = staged.value
293
markSeriesSeen(series, c.core.active.seq, successSeq)
294
}
295
296
for key, staged := range c.core.active.counters {
275
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
297
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
298
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
299
300
hadCurrent := series.desc != nil && series.desc.kind == kindCounter && series.counterCurrentSeq > 0
301
if hadCurrent {
@@ -292,7 +315,8 @@ func (c *storeCycleController) CommitCycleSuccess() {
315
}
316
317
for key, staged := range c.core.active.histograms {
295
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
318
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
319
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
320
321
if series.desc == nil {
322
panic("metrix: missing histogram descriptor")
@@ -319,7 +343,8 @@ func (c *storeCycleController) CommitCycleSuccess() {
343
}
344
345
for key, staged := range c.core.active.summaries {
322
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
346
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
347
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
348
349
if staged.desc.mode == modeStateful && len(staged.desc.summaryQuantiles()) > 0 {
350
if staged.sketch != nil {
@@ -346,20 +371,23 @@ func (c *storeCycleController) CommitCycleSuccess() {
371
}
372
373
for key, staged := range c.core.active.stateSet {
349
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
374
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
375
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
376
377
series.stateSetValues = cloneStateMap(staged.states)
378
markSeriesSeen(series, c.core.active.seq, successSeq)
379
}
380
381
for key, staged := range c.core.active.measureSetGauges {
356
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
382
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
383
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
384
series.measureSetValues = append(series.measureSetValues[:0], staged.values...)
385
markSeriesSeen(series, c.core.active.seq, successSeq)
386
}
387
388
for key, staged := range c.core.active.measureSetCounters {
362
- series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.labels, staged.labelsKey, staged.desc)
389
+ commitHostScopes[staged.hostScopeKey] = staged.hostScope
390
+ series := getOrCreateCommitSeries(oldSnap, next, key, staged.name, staged.hostScopeKey, staged.hostScope, staged.labels, staged.labelsKey, staged.desc)
391
392
if series.desc != nil && series.desc.kind == kindMeasureSet && series.desc.measureSet != nil && series.desc.measureSet.semantics == MeasureSetSemanticsCounter && series.measureSetCurrentSeq > 0 {
393
series.measureSetPreviousValues = append(series.measureSetPreviousValues[:0], series.measureSetValues...)
@@ -376,6 +404,7 @@ func (c *storeCycleController) CommitCycleSuccess() {
404
markSeriesSeen(series, c.core.active.seq, successSeq)
405
}
406
407
+ refreshCommittedHostScopes(oldSnap, next, commitHostScopes)
408
applyCollectorRetention(next.series, c.core.retention, successSeq)
409
next.collectMeta.LastAttemptSeq = c.core.active.seq
410
next.collectMeta.LastAttemptStatus = CollectStatusSuccess
@@ -384,18 +413,21 @@ func (c *storeCycleController) CommitCycleSuccess() {
413
c.core.snapshot.Store(next)
414
c.core.successSeq = successSeq
415
c.core.active = nil
416
+ return nil
417
}
418
389
-func newCommittedSeries(key, name string, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
419
+func newCommittedSeries(key, name, hostScopeKey string, hostScope HostScope, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
420
return &committedSeries{
391
- id: SeriesID(key),
392
- hash64: seriesIDHash(SeriesID(key)),
393
- key: key,
394
- name: name,
395
- labels: append([]Label(nil), labels...),
396
- labelsKey: labelsKey,
397
- desc: desc,
398
- meta: baseSeriesMeta(desc),
421
+ id: SeriesID(key),
422
+ hash64: seriesIDHash(SeriesID(key)),
423
+ key: key,
424
+ name: name,
425
+ hostScopeKey: hostScopeKey,
426
+ hostScope: cloneHostScope(hostScope),
427
+ labels: append([]Label(nil), labels...),
428
+ labelsKey: labelsKey,
429
+ desc: desc,
430
+ meta: baseSeriesMeta(desc),
431
}
432
}
433
@@ -412,16 +444,33 @@ func ensureCommitSeriesMutable(old, next *readSnapshot, key string) *committedSe
444
return series
445
}
446
415
-func getOrCreateCommitSeries(old, next *readSnapshot, key, name string, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
447
+func getOrCreateCommitSeries(old, next *readSnapshot, key, name, hostScopeKey string, hostScope HostScope, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
448
series := ensureCommitSeriesMutable(old, next, key)
449
if series != nil {
450
return series
451
}
420
- series = newCommittedSeries(key, name, labels, labelsKey, desc)
452
+ series = newCommittedSeries(key, name, hostScopeKey, hostScope, labels, labelsKey, desc)
453
next.series[key] = series
454
return series
455
}
456
457
+func refreshCommittedHostScopes(old, next *readSnapshot, scopes map[string]HostScope) {
458
+ if len(scopes) == 0 {
459
+ return
460
+ }
461
+ for key, series := range next.series {
462
+ scope, ok := scopes[series.hostScopeKey]
463
+ if !ok || hostScopeEqual(series.hostScope, scope) {
464
+ continue
465
+ }
466
+ series = ensureCommitSeriesMutable(old, next, key)
467
+ if series == nil {
468
+ continue
469
+ }
470
+ series.hostScope = cloneHostScope(scope)
471
+ }
472
+}
473
+
474
func markSeriesSeen(series *committedSeries, attemptSeq, successSeq uint64) {
475
series.meta.LastSeenSuccessSeq = attemptSeq
476
series.lastSeenSuccessCycle = successSeq
@@ -463,6 +512,9 @@ func buildByName(series map[string]*committedSeries) map[string][]*committedSeri
512
}
513
for _, lst := range byName {
514
sort.Slice(lst, func(i, j int) bool {
515
+ if lst[i].hostScopeKey != lst[j].hostScopeKey {
516
+ return lst[i].hostScopeKey < lst[j].hostScopeKey
517
+ }
518
return lst[i].labelsKey < lst[j].labelsKey
519
})
520
}
@@ -648,17 +700,47 @@ func (c *storeCore) registerInstrument(name string, kind metricKind, mode metric
700
return d, nil
701
}
702
651
-// makeSeriesKey joins metric name and canonical label key into one stable identity key.
652
-func makeSeriesKey(name, labelsKey string) string {
703
+func (c *storeCore) prepareHostScopeForWriteLocked(scope HostScope) (HostScope, bool) {
704
+ scope = mustNormalizeHostScope(scope)
705
+ if c.active == nil {
706
+ return scope, true
707
+ }
708
+ if existing, ok := c.active.hostScopes[scope.ScopeKey]; ok {
709
+ if hostScopeEqual(existing, scope) {
710
+ return existing, true
711
+ }
712
+ c.recordCycleErrorLocked(fmt.Errorf("%w: scope_key=%q", ErrHostScopeConflict, scope.ScopeKey))
713
+ return scope, false
714
+ }
715
+ c.active.hostScopes[scope.ScopeKey] = cloneHostScope(scope)
716
+ return scope, true
717
+}
718
+
719
+func (c *storeCore) recordCycleErrorLocked(err error) {
720
+ if err == nil || c.active == nil {
721
+ return
722
+ }
723
+ c.active.err = errors.Join(c.active.err, err)
724
+}
725
+
726
+// makeSeriesKey joins host scope, metric name, and canonical label key into one stable identity key.
727
+func makeSeriesKey(hostScopeKey, name, labelsKey string) string {
728
+ base := name
729
if labelsKey == "" {
654
- return name
730
+ base = name
731
+ } else {
732
+ base = name + "\xfe" + labelsKey
733
+ }
734
+ if hostScopeKey == "" {
735
+ return base
736
}
656
- return name + "\xfe" + labelsKey
737
+ return hostScopeKey + "\xff" + base
738
}
739
740
func cloneCommittedSeries(s *committedSeries) *committedSeries {
741
cp := *s
742
ensureSeriesMeta(cp.desc, &cp.meta)
743
+ cp.hostScope = cloneHostScope(s.hostScope)
744
// cp.labels intentionally reuses the original immutable label slice.
745
// Label identity is part of the series key and is never mutated after publish.
746
if s.stateSetValues != nil {
src/go/pkg/metrix/counter.go
+41
-23
@@ -6,6 +6,7 @@ package metrix
6
type snapshotCounterInstrument struct {
7
backend meterBackend
8
desc *instrumentDescriptor
9
+ scope HostScope
10
base []LabelSet
11
}
12
@@ -13,17 +14,20 @@ type snapshotCounterInstrument struct {
14
type statefulCounterInstrument struct {
15
backend meterBackend
16
desc *instrumentDescriptor
17
+ scope HostScope
18
base []LabelSet
19
}
20
21
// stagedCounter holds one in-cycle counter current total for a series identity.
22
type stagedCounter struct {
21
- key string
22
- name string
23
- labels []Label
24
- labelsKey string
25
- desc *instrumentDescriptor
26
- current SampleValue
23
+ key string
24
+ name string
25
+ hostScopeKey string
26
+ hostScope HostScope
27
+ labels []Label
28
+ labelsKey string
29
+ desc *instrumentDescriptor
30
+ current SampleValue
31
}
32
33
// Counter declares or reuses a snapshot counter under this meter.
@@ -35,6 +39,7 @@ func (m *snapshotMeter) Counter(name string, opts ...InstrumentOption) SnapshotC
39
return &snapshotCounterInstrument{
40
backend: m.backend,
41
desc: desc,
42
+ scope: m.scope,
43
base: appendLabelSets(m.sets, nil),
44
}
45
}
@@ -48,22 +53,23 @@ func (m *statefulMeter) Counter(name string, opts ...InstrumentOption) StatefulC
53
return &statefulCounterInstrument{
54
backend: m.backend,
55
desc: desc,
56
+ scope: m.scope,
57
base: appendLabelSets(m.sets, nil),
58
}
59
}
60
61
// ObserveTotal writes one monotonic total sample for this collect cycle.
62
func (c *snapshotCounterInstrument) ObserveTotal(v SampleValue, labels ...LabelSet) {
57
- c.backend.recordCounterObserveTotal(c.desc, v, appendLabelSets(c.base, labels))
63
+ c.backend.recordCounterObserveTotal(c.desc, c.scope, v, appendLabelSets(c.base, labels))
64
}
65
66
// Add accumulates a delta for this collect cycle.
67
func (c *statefulCounterInstrument) Add(delta SampleValue, labels ...LabelSet) {
62
- c.backend.recordCounterAdd(c.desc, delta, appendLabelSets(c.base, labels))
68
+ c.backend.recordCounterAdd(c.desc, c.scope, delta, appendLabelSets(c.base, labels))
69
}
70
71
// recordCounterObserveTotal writes one sampled monotonic total for snapshot counters.
66
-func (c *storeCore) recordCounterObserveTotal(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
72
+func (c *storeCore) recordCounterObserveTotal(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
73
mustFiniteSample(value)
74
75
c.mu.Lock()
@@ -77,16 +83,22 @@ func (c *storeCore) recordCounterObserveTotal(desc *instrumentDescriptor, value
83
if err != nil {
84
panic(err)
85
}
86
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
87
+ if !ok {
88
+ return
89
+ }
90
81
- key := makeSeriesKey(desc.name, labelsKey)
91
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
92
entry, ok := c.active.counters[key]
93
if !ok {
94
entry = &stagedCounter{
85
- key: key,
86
- name: desc.name,
87
- labels: labels,
88
- labelsKey: labelsKey,
89
- desc: desc,
95
+ key: key,
96
+ name: desc.name,
97
+ hostScopeKey: scope.ScopeKey,
98
+ hostScope: scope,
99
+ labels: labels,
100
+ labelsKey: labelsKey,
101
+ desc: desc,
102
}
103
c.active.counters[key] = entry
104
}
@@ -94,7 +106,7 @@ func (c *storeCore) recordCounterObserveTotal(desc *instrumentDescriptor, value
106
}
107
108
// recordCounterAdd accumulates delta for stateful counters.
97
-func (c *storeCore) recordCounterAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet) {
109
+func (c *storeCore) recordCounterAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet) {
110
mustFiniteSample(delta)
111
112
if delta < 0 {
@@ -112,8 +124,12 @@ func (c *storeCore) recordCounterAdd(desc *instrumentDescriptor, delta SampleVal
124
if err != nil {
125
panic(err)
126
}
127
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
128
+ if !ok {
129
+ return
130
+ }
131
116
- key := makeSeriesKey(desc.name, labelsKey)
132
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
133
entry, ok := c.active.counters[key]
134
if !ok {
135
baseline := SampleValue(0)
@@ -121,12 +137,14 @@ func (c *storeCore) recordCounterAdd(desc *instrumentDescriptor, delta SampleVal
137
baseline = existing.counterCurrent
138
}
139
entry = &stagedCounter{
124
- key: key,
125
- name: desc.name,
126
- labels: labels,
127
- labelsKey: labelsKey,
128
- desc: desc,
129
- current: baseline,
140
+ key: key,
141
+ name: desc.name,
142
+ hostScopeKey: scope.ScopeKey,
143
+ hostScope: scope,
144
+ labels: labels,
145
+ labelsKey: labelsKey,
146
+ desc: desc,
147
+ current: baseline,
148
}
149
c.active.counters[key] = entry
150
}
src/go/pkg/metrix/errors.go
+3
@@ -33,4 +33,7 @@ var (
33
errRuntimeSnapshotWrite = errors.New("metrix: runtime store supports stateful writes only")
34
errRuntimeFreshness = errors.New("metrix: runtime store freshness is fixed to FreshnessCommitted")
35
errRuntimeWindowCycle = errors.New("metrix: runtime store does not support window=cycle")
36
+
37
+ // ErrHostScopeConflict reports inconsistent metadata for the same non-default host scope key.
38
+ ErrHostScopeConflict = errors.New("metrix: host scope metadata conflict")
39
)
src/go/pkg/metrix/gauge.go
+42
-24
@@ -6,6 +6,7 @@ package metrix
6
type snapshotGaugeInstrument struct {
7
backend meterBackend
8
desc *instrumentDescriptor
9
+ scope HostScope
10
base []LabelSet
11
}
12
@@ -13,17 +14,20 @@ type snapshotGaugeInstrument struct {
14
type statefulGaugeInstrument struct {
15
backend meterBackend
16
desc *instrumentDescriptor
17
+ scope HostScope
18
base []LabelSet
19
}
20
21
// stagedGauge holds one in-cycle gauge sample for a single series identity.
22
type stagedGauge struct {
21
- key string
22
- name string
23
- labels []Label
24
- labelsKey string
25
- desc *instrumentDescriptor
26
- value SampleValue
23
+ key string
24
+ name string
25
+ hostScopeKey string
26
+ hostScope HostScope
27
+ labels []Label
28
+ labelsKey string
29
+ desc *instrumentDescriptor
30
+ value SampleValue
31
}
32
33
// Gauge declares or reuses a snapshot gauge under this meter.
@@ -35,6 +39,7 @@ func (m *snapshotMeter) Gauge(name string, opts ...InstrumentOption) SnapshotGau
39
return &snapshotGaugeInstrument{
40
backend: m.backend,
41
desc: desc,
42
+ scope: m.scope,
43
base: appendLabelSets(m.sets, nil),
44
}
45
}
@@ -48,27 +53,28 @@ func (m *statefulMeter) Gauge(name string, opts ...InstrumentOption) StatefulGau
53
return &statefulGaugeInstrument{
54
backend: m.backend,
55
desc: desc,
56
+ scope: m.scope,
57
base: appendLabelSets(m.sets, nil),
58
}
59
}
60
61
// Observe writes one absolute gauge value for this collect cycle.
62
func (g *snapshotGaugeInstrument) Observe(v SampleValue, labels ...LabelSet) {
57
- g.backend.recordGaugeSet(g.desc, v, appendLabelSets(g.base, labels))
63
+ g.backend.recordGaugeSet(g.desc, g.scope, v, appendLabelSets(g.base, labels))
64
}
65
66
// Set overwrites the staged gauge value for this collect cycle.
67
func (g *statefulGaugeInstrument) Set(v SampleValue, labels ...LabelSet) {
62
- g.backend.recordGaugeSet(g.desc, v, appendLabelSets(g.base, labels))
68
+ g.backend.recordGaugeSet(g.desc, g.scope, v, appendLabelSets(g.base, labels))
69
}
70
71
// Add accumulates on top of the committed baseline for this collect cycle.
72
func (g *statefulGaugeInstrument) Add(delta SampleValue, labels ...LabelSet) {
67
- g.backend.recordGaugeAdd(g.desc, delta, appendLabelSets(g.base, labels))
73
+ g.backend.recordGaugeAdd(g.desc, g.scope, delta, appendLabelSets(g.base, labels))
74
}
75
76
// recordGaugeSet writes one gauge sample into the active frame (last-write-wins in-cycle).
71
-func (c *storeCore) recordGaugeSet(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
77
+func (c *storeCore) recordGaugeSet(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
78
mustFiniteSample(value)
79
80
c.mu.Lock()
@@ -82,16 +88,22 @@ func (c *storeCore) recordGaugeSet(desc *instrumentDescriptor, value SampleValue
88
if err != nil {
89
panic(err)
90
}
91
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
92
+ if !ok {
93
+ return
94
+ }
95
86
- key := makeSeriesKey(desc.name, labelsKey)
96
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
97
entry, ok := c.active.gauges[key]
98
if !ok {
99
entry = &stagedGauge{
90
- key: key,
91
- name: desc.name,
92
- labels: labels,
93
- labelsKey: labelsKey,
94
- desc: desc,
100
+ key: key,
101
+ name: desc.name,
102
+ hostScopeKey: scope.ScopeKey,
103
+ hostScope: scope,
104
+ labels: labels,
105
+ labelsKey: labelsKey,
106
+ desc: desc,
107
}
108
c.active.gauges[key] = entry
109
}
@@ -99,7 +111,7 @@ func (c *storeCore) recordGaugeSet(desc *instrumentDescriptor, value SampleValue
111
}
112
113
// recordGaugeAdd accumulates delta into the active frame using committed baseline on first write.
102
-func (c *storeCore) recordGaugeAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet) {
114
+func (c *storeCore) recordGaugeAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet) {
115
mustFiniteSample(delta)
116
117
c.mu.Lock()
@@ -113,8 +125,12 @@ func (c *storeCore) recordGaugeAdd(desc *instrumentDescriptor, delta SampleValue
125
if err != nil {
126
panic(err)
127
}
128
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
129
+ if !ok {
130
+ return
131
+ }
132
117
- key := makeSeriesKey(desc.name, labelsKey)
133
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
134
entry, ok := c.active.gauges[key]
135
if !ok {
136
baseline := SampleValue(0)
@@ -122,12 +138,14 @@ func (c *storeCore) recordGaugeAdd(desc *instrumentDescriptor, delta SampleValue
138
baseline = existing.value
139
}
140
entry = &stagedGauge{
125
- key: key,
126
- name: desc.name,
127
- labels: labels,
128
- labelsKey: labelsKey,
129
- desc: desc,
130
- value: baseline,
141
+ key: key,
142
+ name: desc.name,
143
+ hostScopeKey: scope.ScopeKey,
144
+ hostScope: scope,
145
+ labels: labels,
146
+ labelsKey: labelsKey,
147
+ desc: desc,
148
+ value: baseline,
149
}
150
c.active.gauges[key] = entry
151
}
src/go/pkg/metrix/histogram.go
+45
-27
@@ -15,6 +15,7 @@ const HistogramBucketLabel = "le"
15
type snapshotHistogramInstrument struct {
16
backend meterBackend
17
desc *instrumentDescriptor
18
+ scope HostScope
19
base []LabelSet
20
}
21
@@ -22,20 +23,23 @@ type snapshotHistogramInstrument struct {
23
type statefulHistogramInstrument struct {
24
backend meterBackend
25
desc *instrumentDescriptor
26
+ scope HostScope
27
base []LabelSet
28
}
29
30
// stagedHistogram holds one in-cycle histogram sample for a single series identity.
31
type stagedHistogram struct {
30
- key string
31
- name string
32
- labels []Label
33
- labelsKey string
34
- desc *instrumentDescriptor
35
- bounds []float64
36
- count SampleValue
37
- sum SampleValue
38
- cumulative []SampleValue
32
+ key string
33
+ name string
34
+ hostScopeKey string
35
+ hostScope HostScope
36
+ labels []Label
37
+ labelsKey string
38
+ desc *instrumentDescriptor
39
+ bounds []float64
40
+ count SampleValue
41
+ sum SampleValue
42
+ cumulative []SampleValue
43
}
44
45
// Histogram declares or reuses a snapshot histogram under this meter.
@@ -47,6 +51,7 @@ func (m *snapshotMeter) Histogram(name string, opts ...InstrumentOption) Snapsho
51
return &snapshotHistogramInstrument{
52
backend: m.backend,
53
desc: desc,
54
+ scope: m.scope,
55
base: appendLabelSets(m.sets, nil),
56
}
57
}
@@ -60,22 +65,23 @@ func (m *statefulMeter) Histogram(name string, opts ...InstrumentOption) Statefu
65
return &statefulHistogramInstrument{
66
backend: m.backend,
67
desc: desc,
68
+ scope: m.scope,
69
base: appendLabelSets(m.sets, nil),
70
}
71
}
72
73
// ObservePoint writes one full histogram point for this collect cycle.
74
func (h *snapshotHistogramInstrument) ObservePoint(p HistogramPoint, labels ...LabelSet) {
69
- h.backend.recordHistogramObservePoint(h.desc, p, appendLabelSets(h.base, labels))
75
+ h.backend.recordHistogramObservePoint(h.desc, h.scope, p, appendLabelSets(h.base, labels))
76
}
77
78
// Observe adds one sample to a stateful histogram for this collect cycle.
79
func (h *statefulHistogramInstrument) Observe(v SampleValue, labels ...LabelSet) {
74
- h.backend.recordHistogramObserve(h.desc, v, appendLabelSets(h.base, labels))
80
+ h.backend.recordHistogramObserve(h.desc, h.scope, v, appendLabelSets(h.base, labels))
81
}
82
83
// recordHistogramObservePoint writes one full histogram point into the active frame.
78
-func (c *storeCore) recordHistogramObservePoint(desc *instrumentDescriptor, point HistogramPoint, sets []LabelSet) {
84
+func (c *storeCore) recordHistogramObservePoint(desc *instrumentDescriptor, scope HostScope, point HistogramPoint, sets []LabelSet) {
85
c.mu.Lock()
86
defer c.mu.Unlock()
87
@@ -90,6 +96,10 @@ func (c *storeCore) recordHistogramObservePoint(desc *instrumentDescriptor, poin
96
if labelsContainKey(labels, HistogramBucketLabel) {
97
panic(errHistogramLabelKey)
98
}
99
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
100
+ if !ok {
101
+ return
102
+ }
103
104
schema := desc.histogram
105
if schema == nil {
@@ -99,15 +109,17 @@ func (c *storeCore) recordHistogramObservePoint(desc *instrumentDescriptor, poin
109
}
110
bounds, count, sum, cumulative := normalizeHistogramPoint(point, schema)
111
102
- key := makeSeriesKey(desc.name, labelsKey)
112
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
113
entry, ok := c.active.histograms[key]
114
if !ok {
115
entry = &stagedHistogram{
106
- key: key,
107
- name: desc.name,
108
- labels: labels,
109
- labelsKey: labelsKey,
110
- desc: desc,
116
+ key: key,
117
+ name: desc.name,
118
+ hostScopeKey: scope.ScopeKey,
119
+ hostScope: scope,
120
+ labels: labels,
121
+ labelsKey: labelsKey,
122
+ desc: desc,
123
}
124
c.active.histograms[key] = entry
125
}
@@ -121,7 +133,7 @@ func (c *storeCore) recordHistogramObservePoint(desc *instrumentDescriptor, poin
133
}
134
135
// recordHistogramObserve adds one sample to a stateful histogram in the active frame.
124
-func (c *storeCore) recordHistogramObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
136
+func (c *storeCore) recordHistogramObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
137
mustFiniteSample(value)
138
139
c.mu.Lock()
@@ -143,18 +155,24 @@ func (c *storeCore) recordHistogramObserve(desc *instrumentDescriptor, value Sam
155
if labelsContainKey(labels, HistogramBucketLabel) {
156
panic(errHistogramLabelKey)
157
}
158
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
159
+ if !ok {
160
+ return
161
+ }
162
147
- key := makeSeriesKey(desc.name, labelsKey)
163
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
164
entry, ok := c.active.histograms[key]
165
if !ok {
166
entry = &stagedHistogram{
151
- key: key,
152
- name: desc.name,
153
- labels: labels,
154
- labelsKey: labelsKey,
155
- desc: desc,
156
- bounds: append([]float64(nil), schema.bounds...),
157
- cumulative: make([]SampleValue, len(schema.bounds)),
167
+ key: key,
168
+ name: desc.name,
169
+ hostScopeKey: scope.ScopeKey,
170
+ hostScope: scope,
171
+ labels: labels,
172
+ labelsKey: labelsKey,
173
+ desc: desc,
174
+ bounds: append([]float64(nil), schema.bounds...),
175
+ cumulative: make([]SampleValue, len(schema.bounds)),
176
}
177
if desc.window == WindowCumulative {
178
if existing := c.snapshot.Load().series[key]; existing != nil && existing.desc != nil && existing.desc.kind == kindHistogram {
src/go/pkg/metrix/host_scope.go
new
+107
@@ -0,0 +1,107 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package metrix
4
+
5
+import (
6
+ "fmt"
7
+ "maps"
8
+ "sort"
9
+ "strings"
10
+)
11
+
12
+// HostScope identifies the target host/vnode partition for metric series.
13
+//
14
+// The zero value is the default scope and is equivalent to unscoped writes.
15
+// Non-default scopes must have a stable ScopeKey plus host metadata.
16
+type HostScope struct {
17
+ ScopeKey string
18
+ GUID string
19
+ Hostname string
20
+ Labels map[string]string
21
+}
22
+
23
+// IsDefault reports whether this scope is the default unscoped partition.
24
+func (s HostScope) IsDefault() bool {
25
+ return strings.TrimSpace(s.ScopeKey) == ""
26
+}
27
+
28
+func normalizeHostScope(scope HostScope) (HostScope, error) {
29
+ out := HostScope{
30
+ ScopeKey: strings.TrimSpace(scope.ScopeKey),
31
+ GUID: strings.TrimSpace(scope.GUID),
32
+ Hostname: strings.TrimSpace(scope.Hostname),
33
+ }
34
+ if strings.ContainsAny(out.ScopeKey, "\xfe\xff") {
35
+ return HostScope{}, fmt.Errorf("metrix: host scope key contains reserved separator")
36
+ }
37
+ if out.ScopeKey == "" {
38
+ if out.GUID != "" || out.Hostname != "" || len(scope.Labels) > 0 {
39
+ return HostScope{}, fmt.Errorf("metrix: default host scope cannot carry vnode metadata")
40
+ }
41
+ return HostScope{}, nil
42
+ }
43
+ if out.GUID == "" {
44
+ return HostScope{}, fmt.Errorf("metrix: host scope guid is required")
45
+ }
46
+ if out.Hostname == "" {
47
+ return HostScope{}, fmt.Errorf("metrix: host scope hostname is required")
48
+ }
49
+ if len(scope.Labels) > 0 {
50
+ out.Labels = make(map[string]string, len(scope.Labels))
51
+ for key, value := range scope.Labels {
52
+ k := strings.TrimSpace(key)
53
+ if k == "" {
54
+ return HostScope{}, fmt.Errorf("metrix: host scope label key is required")
55
+ }
56
+ if _, ok := out.Labels[k]; ok {
57
+ return HostScope{}, fmt.Errorf("metrix: duplicate host scope label key %q", k)
58
+ }
59
+ out.Labels[k] = strings.TrimSpace(value)
60
+ }
61
+ }
62
+ return out, nil
63
+}
64
+
65
+func mustNormalizeHostScope(scope HostScope) HostScope {
66
+ out, err := normalizeHostScope(scope)
67
+ if err != nil {
68
+ panic(err)
69
+ }
70
+ return out
71
+}
72
+
73
+func cloneHostScope(scope HostScope) HostScope {
74
+ if scope.Labels != nil {
75
+ scope.Labels = maps.Clone(scope.Labels)
76
+ }
77
+ return scope
78
+}
79
+
80
+func hostScopeEqual(a, b HostScope) bool {
81
+ if a.ScopeKey != b.ScopeKey || a.GUID != b.GUID || a.Hostname != b.Hostname {
82
+ return false
83
+ }
84
+ return maps.Equal(a.Labels, b.Labels)
85
+}
86
+
87
+func sortedHostScopes(scopes map[string]HostScope) []HostScope {
88
+ if len(scopes) == 0 {
89
+ return nil
90
+ }
91
+ keys := make([]string, 0, len(scopes))
92
+ for key := range scopes {
93
+ keys = append(keys, key)
94
+ }
95
+ sort.Strings(keys)
96
+ out := make([]HostScope, 0, len(keys))
97
+ if scope, ok := scopes[""]; ok {
98
+ out = append(out, cloneHostScope(scope))
99
+ }
100
+ for _, key := range keys {
101
+ if key == "" {
102
+ continue
103
+ }
104
+ out = append(out, cloneHostScope(scopes[key]))
105
+ }
106
+ return out
107
+}
src/go/pkg/metrix/host_scope_test.go
new
+397
@@ -0,0 +1,397 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package metrix
4
+
5
+import (
6
+ "errors"
7
+ "sync"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/require"
11
+)
12
+
13
+func TestHostScopePartitionsSeriesIdentity(t *testing.T) {
14
+ store := NewCollectorStore()
15
+ cc := cycleController(t, store)
16
+
17
+ scope := HostScope{
18
+ ScopeKey: "workload/api",
19
+ GUID: "guid-api",
20
+ Hostname: "api",
21
+ Labels: map[string]string{"_vnode_type": "azure_workload"},
22
+ }
23
+
24
+ meter := store.Write().SnapshotMeter("azure")
25
+ defaultGauge := meter.Gauge("requests")
26
+ scopedGauge := meter.WithHostScope(scope).Gauge("requests")
27
+ labels := meter.LabelSet(Label{Key: "resource", Value: "vm1"})
28
+
29
+ cc.BeginCycle()
30
+ defaultGauge.Observe(1, labels)
31
+ scopedGauge.Observe(2, labels)
32
+ require.NoError(t, cc.CommitCycleSuccess())
33
+
34
+ mustValue(t, store.Read(), "azure.requests", Labels{"resource": "vm1"}, 1)
35
+ mustValue(t, store.Read(ReadHostScope(scope.ScopeKey)), "azure.requests", Labels{"resource": "vm1"}, 2)
36
+
37
+ _, ok := store.Read().Value("azure.requests", Labels{"resource": "vm2"})
38
+ require.False(t, ok)
39
+ _, ok = store.Read(ReadHostScope(scope.ScopeKey)).Value("azure.requests", Labels{"resource": "vm2"})
40
+ require.False(t, ok)
41
+
42
+ scopes := store.Read().HostScopes()
43
+ require.Len(t, scopes, 2)
44
+ require.True(t, scopes[0].IsDefault())
45
+ require.Equal(t, scope, scopes[1])
46
+
47
+ filteredScopes := store.Read(ReadHostScope(scope.ScopeKey)).HostScopes()
48
+ require.Equal(t, scopes, filteredScopes)
49
+
50
+ var defaultID, scopedID SeriesID
51
+ store.Read().ForEachSeriesIdentity(func(identity SeriesIdentity, _ SeriesMeta, name string, _ LabelView, _ SampleValue) {
52
+ if name == "azure.requests" {
53
+ defaultID = identity.ID
54
+ }
55
+ })
56
+ store.Read(ReadHostScope(scope.ScopeKey)).ForEachSeriesIdentity(func(identity SeriesIdentity, _ SeriesMeta, name string, _ LabelView, _ SampleValue) {
57
+ if name == "azure.requests" {
58
+ scopedID = identity.ID
59
+ }
60
+ })
61
+ require.NotEmpty(t, defaultID)
62
+ require.NotEmpty(t, scopedID)
63
+ require.NotEqual(t, defaultID, scopedID)
64
+}
65
+
66
+func TestHostScopeMetadataRefreshesRetainedSeries(t *testing.T) {
67
+ store := NewCollectorStore()
68
+ cc := cycleController(t, store)
69
+
70
+ scopeV1 := HostScope{
71
+ ScopeKey: "workload/api",
72
+ GUID: "guid-api",
73
+ Hostname: "api",
74
+ Labels: map[string]string{"_vnode_type": "azure_workload"},
75
+ }
76
+ scopeV2 := HostScope{
77
+ ScopeKey: "workload/api",
78
+ GUID: "guid-api",
79
+ Hostname: "api-v2",
80
+ Labels: map[string]string{"_vnode_type": "azure_workload", "region": "eastus"},
81
+ }
82
+ meter := store.Write().SnapshotMeter("azure")
83
+ labels := meter.LabelSet(Label{Key: "resource", Value: "vm1"})
84
+
85
+ cc.BeginCycle()
86
+ meter.WithHostScope(scopeV1).Gauge("requests").Observe(1, labels)
87
+ meter.WithHostScope(scopeV1).Gauge("errors").Observe(2, labels)
88
+ require.NoError(t, cc.CommitCycleSuccess())
89
+
90
+ cc.BeginCycle()
91
+ meter.WithHostScope(scopeV2).Gauge("requests").Observe(3, labels)
92
+ require.NoError(t, cc.CommitCycleSuccess())
93
+
94
+ require.Equal(t, []HostScope{scopeV2}, store.Read().HostScopes())
95
+
96
+ snapshot := store.(*storeView).core.snapshot.Load()
97
+ seen := 0
98
+ for _, series := range snapshot.series {
99
+ if series.hostScopeKey != scopeV2.ScopeKey {
100
+ continue
101
+ }
102
+ seen++
103
+ require.Equal(t, scopeV2, series.hostScope)
104
+ }
105
+ require.Equal(t, 2, seen)
106
+}
107
+
108
+func TestHostScopeVecDerivationPartitionsSeries(t *testing.T) {
109
+ store := NewCollectorStore()
110
+ cc := cycleController(t, store)
111
+
112
+ scopeA := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
113
+ scopeB := HostScope{ScopeKey: "workload/db", GUID: "guid-db", Hostname: "db"}
114
+ vec := store.Write().SnapshotMeter("azure").Vec("resource").Gauge("cpu")
115
+
116
+ cc.BeginCycle()
117
+ vec.WithLabelValues("vm1").Observe(10)
118
+ vec.WithHostScope(scopeA).WithLabelValues("vm1").Observe(20)
119
+ vec.WithHostScope(scopeB).WithLabelValues("vm1").Observe(30)
120
+ require.NoError(t, cc.CommitCycleSuccess())
121
+
122
+ mustValue(t, store.Read(), "azure.cpu", Labels{"resource": "vm1"}, 10)
123
+ mustValue(t, store.Read(ReadHostScope(scopeA.ScopeKey)), "azure.cpu", Labels{"resource": "vm1"}, 20)
124
+ mustValue(t, store.Read(ReadHostScope(scopeB.ScopeKey)), "azure.cpu", Labels{"resource": "vm1"}, 30)
125
+
126
+ missingScopeReader := store.Read(ReadHostScope("workload/missing"))
127
+ _, ok := missingScopeReader.Value("azure.cpu", Labels{"resource": "vm1"})
128
+ require.False(t, ok)
129
+ _, ok = missingScopeReader.Family("azure.cpu")
130
+ require.False(t, ok)
131
+}
132
+
133
+func TestHostScopeFlattenPreservesScopeScenarios(t *testing.T) {
134
+ cases := map[string]struct {
135
+ run func(t *testing.T)
136
+ }{
137
+ "histogram": {
138
+ run: func(t *testing.T) {
139
+ store := NewCollectorStore()
140
+ cc := cycleController(t, store)
141
+
142
+ scope := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
143
+ hist := store.Write().SnapshotMeter("svc").WithHostScope(scope).Histogram("latency")
144
+ labels := store.Write().SnapshotMeter("").LabelSet(Label{Key: "route", Value: "/api"})
145
+
146
+ cc.BeginCycle()
147
+ hist.ObservePoint(HistogramPoint{
148
+ Count: 3,
149
+ Sum: 6,
150
+ Buckets: []BucketPoint{
151
+ {UpperBound: 1, CumulativeCount: 1},
152
+ {UpperBound: 5, CumulativeCount: 3},
153
+ },
154
+ }, labels)
155
+ require.NoError(t, cc.CommitCycleSuccess())
156
+
157
+ defaultReader := store.Read(ReadFlatten())
158
+ _, ok := defaultReader.Value("svc.latency_count", Labels{"route": "/api"})
159
+ require.False(t, ok)
160
+
161
+ scopedReader := store.Read(ReadFlatten(), ReadHostScope(scope.ScopeKey))
162
+ mustValue(t, scopedReader, "svc.latency_count", Labels{"route": "/api"}, 3)
163
+ mustValue(t, scopedReader, "svc.latency_sum", Labels{"route": "/api"}, 6)
164
+ mustValue(t, scopedReader, "svc.latency_bucket", Labels{"route": "/api", HistogramBucketLabel: "1"}, 1)
165
+ mustValue(t, scopedReader, "svc.latency_bucket", Labels{"route": "/api", HistogramBucketLabel: "5"}, 3)
166
+ },
167
+ },
168
+ "composite instruments": {
169
+ run: func(t *testing.T) {
170
+ store := NewCollectorStore()
171
+ cc := cycleController(t, store)
172
+
173
+ scope := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
174
+ meter := store.Write().SnapshotMeter("svc")
175
+ labels := meter.LabelSet(Label{Key: "route", Value: "/api"})
176
+ summary := meter.WithHostScope(scope).Summary("latency", WithSummaryQuantiles(0.5))
177
+ stateSet := meter.WithHostScope(scope).StateSet("mode", WithStateSetStates("idle", "busy"))
178
+ measureSet := meter.WithHostScope(scope).MeasureSetGauge(
179
+ "usage",
180
+ WithMeasureSetFields(
181
+ MeasureFieldSpec{Name: "used"},
182
+ MeasureFieldSpec{Name: "limit"},
183
+ ),
184
+ )
185
+
186
+ cc.BeginCycle()
187
+ summary.ObservePoint(SummaryPoint{
188
+ Count: 2,
189
+ Sum: 1,
190
+ Quantiles: []QuantilePoint{
191
+ {Quantile: 0.5, Value: 0.4},
192
+ },
193
+ }, labels)
194
+ stateSet.ObserveStateSet(StateSetPoint{States: map[string]bool{"busy": true}}, labels)
195
+ measureSet.ObserveFields(map[string]SampleValue{"used": 7, "limit": 10}, labels)
196
+ require.NoError(t, cc.CommitCycleSuccess())
197
+
198
+ defaultReader := store.Read(ReadFlatten())
199
+ _, ok := defaultReader.Value("svc.latency_count", Labels{"route": "/api"})
200
+ require.False(t, ok)
201
+ _, ok = defaultReader.Value("svc.mode", Labels{"route": "/api", "svc.mode": "busy"})
202
+ require.False(t, ok)
203
+ _, ok = defaultReader.Value("svc.usage_used", Labels{"route": "/api", MeasureSetFieldLabel: "used"})
204
+ require.False(t, ok)
205
+
206
+ scopedReader := store.Read(ReadFlatten(), ReadHostScope(scope.ScopeKey))
207
+ mustValue(t, scopedReader, "svc.latency_count", Labels{"route": "/api"}, 2)
208
+ mustValue(t, scopedReader, "svc.latency_sum", Labels{"route": "/api"}, 1)
209
+ mustValue(t, scopedReader, "svc.latency", Labels{"route": "/api", SummaryQuantileLabel: "0.5"}, 0.4)
210
+ mustValue(t, scopedReader, "svc.mode", Labels{"route": "/api", "svc.mode": "idle"}, 0)
211
+ mustValue(t, scopedReader, "svc.mode", Labels{"route": "/api", "svc.mode": "busy"}, 1)
212
+ mustValue(t, scopedReader, "svc.usage_used", Labels{"route": "/api", MeasureSetFieldLabel: "used"}, 7)
213
+ mustValue(t, scopedReader, "svc.usage_limit", Labels{"route": "/api", MeasureSetFieldLabel: "limit"}, 10)
214
+ },
215
+ },
216
+ }
217
+
218
+ for name, tc := range cases {
219
+ t.Run(name, tc.run)
220
+ }
221
+}
222
+
223
+func TestHostScopeStatefulBaselinesArePerScope(t *testing.T) {
224
+ store := NewCollectorStore()
225
+ cc := cycleController(t, store)
226
+
227
+ scopeA := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
228
+ scopeB := HostScope{ScopeKey: "workload/db", GUID: "guid-db", Hostname: "db"}
229
+ meter := store.Write().StatefulMeter("svc")
230
+ gaugeA := meter.WithHostScope(scopeA).Gauge("load")
231
+ gaugeB := meter.WithHostScope(scopeB).Gauge("load")
232
+ counterA := meter.WithHostScope(scopeA).Counter("requests")
233
+ counterB := meter.WithHostScope(scopeB).Counter("requests")
234
+ labels := meter.LabelSet(Label{Key: "resource", Value: "vm1"})
235
+
236
+ cc.BeginCycle()
237
+ gaugeA.Add(1, labels)
238
+ gaugeB.Add(10, labels)
239
+ counterA.Add(1, labels)
240
+ counterB.Add(10, labels)
241
+ require.NoError(t, cc.CommitCycleSuccess())
242
+
243
+ cc.BeginCycle()
244
+ gaugeA.Add(2, labels)
245
+ gaugeB.Add(3, labels)
246
+ counterA.Add(2, labels)
247
+ counterB.Add(3, labels)
248
+ require.NoError(t, cc.CommitCycleSuccess())
249
+
250
+ mustValue(t, store.Read(ReadHostScope(scopeA.ScopeKey)), "svc.load", Labels{"resource": "vm1"}, 3)
251
+ mustValue(t, store.Read(ReadHostScope(scopeB.ScopeKey)), "svc.load", Labels{"resource": "vm1"}, 13)
252
+ mustValue(t, store.Read(ReadHostScope(scopeA.ScopeKey)), "svc.requests", Labels{"resource": "vm1"}, 3)
253
+ mustValue(t, store.Read(ReadHostScope(scopeB.ScopeKey)), "svc.requests", Labels{"resource": "vm1"}, 13)
254
+ mustDelta(t, store.Read(ReadHostScope(scopeA.ScopeKey)), "svc.requests", Labels{"resource": "vm1"}, 2)
255
+ mustDelta(t, store.Read(ReadHostScope(scopeB.ScopeKey)), "svc.requests", Labels{"resource": "vm1"}, 3)
256
+
257
+ _, ok := store.Read().Value("svc.load", Labels{"resource": "vm1"})
258
+ require.False(t, ok)
259
+}
260
+
261
+func TestHostScopeStatefulHistogramCumulativeWindowIsPerScope(t *testing.T) {
262
+ store := NewCollectorStore()
263
+ cc := cycleController(t, store)
264
+
265
+ scopeA := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
266
+ scopeB := HostScope{ScopeKey: "workload/db", GUID: "guid-db", Hostname: "db"}
267
+ meter := store.Write().StatefulMeter("svc")
268
+ histA := meter.WithHostScope(scopeA).Histogram("latency", WithHistogramBounds(1, 2))
269
+ histB := meter.WithHostScope(scopeB).Histogram("latency", WithHistogramBounds(1, 2))
270
+ labels := meter.LabelSet(Label{Key: "resource", Value: "vm1"})
271
+
272
+ cc.BeginCycle()
273
+ histA.Observe(0.5, labels)
274
+ histA.Observe(1.5, labels)
275
+ histB.Observe(3, labels)
276
+ require.NoError(t, cc.CommitCycleSuccess())
277
+
278
+ cc.BeginCycle()
279
+ histA.Observe(0.2, labels)
280
+ histB.Observe(0.8, labels)
281
+ require.NoError(t, cc.CommitCycleSuccess())
282
+
283
+ mustHistogram(t, store.Read(ReadHostScope(scopeA.ScopeKey)), "svc.latency", Labels{"resource": "vm1"}, HistogramPoint{
284
+ Count: 3,
285
+ Sum: 2.2,
286
+ Buckets: []BucketPoint{
287
+ {UpperBound: 1, CumulativeCount: 2},
288
+ {UpperBound: 2, CumulativeCount: 3},
289
+ },
290
+ })
291
+ mustHistogram(t, store.Read(ReadHostScope(scopeB.ScopeKey)), "svc.latency", Labels{"resource": "vm1"}, HistogramPoint{
292
+ Count: 2,
293
+ Sum: 3.8,
294
+ Buckets: []BucketPoint{
295
+ {UpperBound: 1, CumulativeCount: 1},
296
+ {UpperBound: 2, CumulativeCount: 1},
297
+ },
298
+ })
299
+}
300
+
301
+func TestHostScopeConflictScenarios(t *testing.T) {
302
+ cases := map[string]struct {
303
+ run func(t *testing.T)
304
+ }{
305
+ "commit fails without publishing": {
306
+ run: func(t *testing.T) {
307
+ store := NewCollectorStore()
308
+ cc := cycleController(t, store)
309
+
310
+ scopeA := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
311
+ scopeB := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api-v2"}
312
+ meter := store.Write().SnapshotMeter("azure")
313
+ a := meter.WithHostScope(scopeA).Gauge("requests")
314
+ b := meter.WithHostScope(scopeB).Gauge("requests")
315
+
316
+ cc.BeginCycle()
317
+ a.Observe(1)
318
+ b.Observe(2)
319
+ err := cc.CommitCycleSuccess()
320
+ require.Error(t, err)
321
+ require.True(t, errors.Is(err, ErrHostScopeConflict), "unexpected error: %v", err)
322
+
323
+ meta := store.Read().CollectMeta()
324
+ require.Equal(t, CollectStatusFailed, meta.LastAttemptStatus)
325
+ require.Equal(t, uint64(1), meta.LastAttemptSeq)
326
+ require.Equal(t, uint64(0), meta.LastSuccessSeq)
327
+
328
+ _, ok := store.Read(ReadRaw(), ReadHostScope(scopeA.ScopeKey)).Value("azure.requests", nil)
329
+ require.False(t, ok)
330
+ },
331
+ },
332
+ "multiple conflicts join errors": {
333
+ run: func(t *testing.T) {
334
+ store := NewCollectorStore()
335
+ cc := cycleController(t, store)
336
+
337
+ scopeA := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"}
338
+ scopeAConflict := HostScope{ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api-v2"}
339
+ scopeB := HostScope{ScopeKey: "workload/db", GUID: "guid-db", Hostname: "db"}
340
+ scopeBConflict := HostScope{ScopeKey: "workload/db", GUID: "guid-db-v2", Hostname: "db"}
341
+ meter := store.Write().SnapshotMeter("azure")
342
+
343
+ cc.BeginCycle()
344
+ meter.WithHostScope(scopeA).Gauge("requests").Observe(1)
345
+ meter.WithHostScope(scopeAConflict).Gauge("requests").Observe(2)
346
+ meter.WithHostScope(scopeB).Gauge("requests").Observe(3)
347
+ meter.WithHostScope(scopeBConflict).Gauge("requests").Observe(4)
348
+ err := cc.CommitCycleSuccess()
349
+ require.Error(t, err)
350
+ require.True(t, errors.Is(err, ErrHostScopeConflict), "unexpected error: %v", err)
351
+ require.Contains(t, err.Error(), "scope_key=\"workload/api\"")
352
+ require.Contains(t, err.Error(), "scope_key=\"workload/db\"")
353
+ },
354
+ },
355
+ }
356
+
357
+ for name, tc := range cases {
358
+ t.Run(name, tc.run)
359
+ }
360
+}
361
+
362
+func TestHostScopeConcurrentWrites(t *testing.T) {
363
+ store := NewCollectorStore()
364
+ cc := cycleController(t, store)
365
+
366
+ scopes := []HostScope{
367
+ {ScopeKey: "workload/api", GUID: "guid-api", Hostname: "api"},
368
+ {ScopeKey: "workload/db", GUID: "guid-db", Hostname: "db"},
369
+ {ScopeKey: "workload/cache", GUID: "guid-cache", Hostname: "cache"},
370
+ }
371
+ meter := store.Write().StatefulMeter("svc")
372
+ counters := make([]StatefulCounter, len(scopes))
373
+ for i, scope := range scopes {
374
+ counters[i] = meter.WithHostScope(scope).Counter("requests")
375
+ }
376
+
377
+ const writersPerScope = 8
378
+ const writesPerWriter = 100
379
+
380
+ var wg sync.WaitGroup
381
+ cc.BeginCycle()
382
+ for i := range scopes {
383
+ for range writersPerScope {
384
+ wg.Go(func() {
385
+ for range writesPerWriter {
386
+ counters[i].Add(1)
387
+ }
388
+ })
389
+ }
390
+ }
391
+ wg.Wait()
392
+ require.NoError(t, cc.CommitCycleSuccess())
393
+
394
+ for _, scope := range scopes {
395
+ mustValue(t, store.Read(ReadHostScope(scope.ScopeKey)), "svc.requests", nil, writersPerScope*writesPerWriter)
396
+ }
397
+}
src/go/pkg/metrix/interfaces.go
+23
-1
@@ -20,7 +20,7 @@ type RuntimeStore interface {
20
// Collector code does not call these methods directly.
21
type CycleController interface {
22
BeginCycle()
23
- CommitCycleSuccess()
23
+ CommitCycleSuccess() error
24
AbortCycle()
25
}
26
@@ -45,6 +45,10 @@ type Reader interface {
45
// Example: histogram families resolve via *_bucket/*_count/*_sum names.
46
MetricMeta(name string) (MetricMeta, bool)
47
CollectMeta() CollectMeta
48
+ // HostScopes returns all host scopes present in the snapshot, including the
49
+ // default scope when it has series. The result is not filtered by the
50
+ // reader's active scope.
51
+ HostScopes() []HostScope
52
// Family returns a scalar-only view. For non-scalar families use Histogram/Summary/StateSet,
53
// or use Read(ReadFlatten()) at reader acquisition time.
54
Family(name string) (FamilyView, bool)
@@ -86,6 +90,7 @@ type RuntimeWriter interface {
90
91
// SnapshotMeter declares snapshot-mode instruments under a metric-name prefix.
92
type SnapshotMeter interface {
93
+ WithHostScope(scope HostScope) SnapshotMeter
94
WithLabels(labels ...Label) SnapshotMeter
95
WithLabelSet(labels ...LabelSet) SnapshotMeter
96
// Vec binds a reusable vec label-key schema for multiple vector instruments.
@@ -102,6 +107,7 @@ type SnapshotMeter interface {
107
108
// SnapshotVecMeter declares snapshot vec instruments sharing one label-key schema.
109
type SnapshotVecMeter interface {
110
+ WithHostScope(scope HostScope) SnapshotVecMeter
111
Gauge(name string, opts ...InstrumentOption) SnapshotGaugeVec
112
Counter(name string, opts ...InstrumentOption) SnapshotCounterVec
113
Histogram(name string, opts ...InstrumentOption) SnapshotHistogramVec
@@ -113,6 +119,7 @@ type SnapshotVecMeter interface {
119
120
// StatefulMeter declares stateful-mode instruments under a metric-name prefix.
121
type StatefulMeter interface {
122
+ WithHostScope(scope HostScope) StatefulMeter
123
WithLabels(labels ...Label) StatefulMeter
124
WithLabelSet(labels ...LabelSet) StatefulMeter
125
// Vec binds a reusable vec label-key schema for multiple vector instruments.
@@ -129,6 +136,7 @@ type StatefulMeter interface {
136
137
// StatefulVecMeter declares stateful vec instruments sharing one label-key schema.
138
type StatefulVecMeter interface {
139
+ WithHostScope(scope HostScope) StatefulVecMeter
140
Gauge(name string, opts ...InstrumentOption) StatefulGaugeVec
141
Counter(name string, opts ...InstrumentOption) StatefulCounterVec
142
Histogram(name string, opts ...InstrumentOption) StatefulHistogramVec
@@ -148,6 +156,7 @@ type SnapshotGauge interface {
156
// - GetWithLabelValues returns (metric, error)
157
// - WithLabelValues panics on invalid label values
158
type SnapshotGaugeVec interface {
159
+ WithHostScope(scope HostScope) SnapshotGaugeVec
160
GetWithLabelValues(labelValues ...string) (SnapshotGauge, error)
161
WithLabelValues(labelValues ...string) SnapshotGauge
162
}
@@ -161,6 +170,7 @@ type StatefulGauge interface {
170
171
// StatefulGaugeVec provides labeled series handles for stateful gauges.
172
type StatefulGaugeVec interface {
173
+ WithHostScope(scope HostScope) StatefulGaugeVec
174
GetWithLabelValues(labelValues ...string) (StatefulGauge, error)
175
WithLabelValues(labelValues ...string) StatefulGauge
176
}
@@ -171,6 +181,7 @@ type SnapshotCounter interface {
181
182
// SnapshotCounterVec provides labeled series handles for snapshot counters.
183
type SnapshotCounterVec interface {
184
+ WithHostScope(scope HostScope) SnapshotCounterVec
185
GetWithLabelValues(labelValues ...string) (SnapshotCounter, error)
186
WithLabelValues(labelValues ...string) SnapshotCounter
187
}
@@ -181,6 +192,7 @@ type StatefulCounter interface {
192
193
// StatefulCounterVec provides labeled series handles for stateful counters.
194
type StatefulCounterVec interface {
195
+ WithHostScope(scope HostScope) StatefulCounterVec
196
GetWithLabelValues(labelValues ...string) (StatefulCounter, error)
197
WithLabelValues(labelValues ...string) StatefulCounter
198
}
@@ -191,6 +203,7 @@ type SnapshotHistogram interface {
203
204
// SnapshotHistogramVec provides labeled series handles for snapshot histograms.
205
type SnapshotHistogramVec interface {
206
+ WithHostScope(scope HostScope) SnapshotHistogramVec
207
GetWithLabelValues(labelValues ...string) (SnapshotHistogram, error)
208
WithLabelValues(labelValues ...string) SnapshotHistogram
209
}
@@ -201,6 +214,7 @@ type StatefulHistogram interface {
214
215
// StatefulHistogramVec provides labeled series handles for stateful histograms.
216
type StatefulHistogramVec interface {
217
+ WithHostScope(scope HostScope) StatefulHistogramVec
218
GetWithLabelValues(labelValues ...string) (StatefulHistogram, error)
219
WithLabelValues(labelValues ...string) StatefulHistogram
220
}
@@ -211,6 +225,7 @@ type SnapshotSummary interface {
225
226
// SnapshotSummaryVec provides labeled series handles for snapshot summaries.
227
type SnapshotSummaryVec interface {
228
+ WithHostScope(scope HostScope) SnapshotSummaryVec
229
GetWithLabelValues(labelValues ...string) (SnapshotSummary, error)
230
WithLabelValues(labelValues ...string) SnapshotSummary
231
}
@@ -221,6 +236,7 @@ type StatefulSummary interface {
236
237
// StatefulSummaryVec provides labeled series handles for stateful summaries.
238
type StatefulSummaryVec interface {
239
+ WithHostScope(scope HostScope) StatefulSummaryVec
240
GetWithLabelValues(labelValues ...string) (StatefulSummary, error)
241
WithLabelValues(labelValues ...string) StatefulSummary
242
}
@@ -236,6 +252,7 @@ type SnapshotMeasureSetGauge interface {
252
}
253
254
type SnapshotMeasureSetGaugeVec interface {
255
+ WithHostScope(scope HostScope) SnapshotMeasureSetGaugeVec
256
GetWithLabelValues(labelValues ...string) (SnapshotMeasureSetGauge, error)
257
WithLabelValues(labelValues ...string) SnapshotMeasureSetGauge
258
}
@@ -246,6 +263,7 @@ type SnapshotMeasureSetCounter interface {
263
}
264
265
type SnapshotMeasureSetCounterVec interface {
266
+ WithHostScope(scope HostScope) SnapshotMeasureSetCounterVec
267
GetWithLabelValues(labelValues ...string) (SnapshotMeasureSetCounter, error)
268
WithLabelValues(labelValues ...string) SnapshotMeasureSetCounter
269
}
@@ -260,6 +278,7 @@ type StatefulMeasureSetGauge interface {
278
}
279
280
type StatefulMeasureSetGaugeVec interface {
281
+ WithHostScope(scope HostScope) StatefulMeasureSetGaugeVec
282
GetWithLabelValues(labelValues ...string) (StatefulMeasureSetGauge, error)
283
WithLabelValues(labelValues ...string) StatefulMeasureSetGauge
284
}
@@ -271,18 +290,21 @@ type StatefulMeasureSetCounter interface {
290
}
291
292
type StatefulMeasureSetCounterVec interface {
293
+ WithHostScope(scope HostScope) StatefulMeasureSetCounterVec
294
GetWithLabelValues(labelValues ...string) (StatefulMeasureSetCounter, error)
295
WithLabelValues(labelValues ...string) StatefulMeasureSetCounter
296
}
297
298
// SnapshotStateSetVec provides labeled series handles for snapshot statesets.
299
type SnapshotStateSetVec interface {
300
+ WithHostScope(scope HostScope) SnapshotStateSetVec
301
GetWithLabelValues(labelValues ...string) (StateSetInstrument, error)
302
WithLabelValues(labelValues ...string) StateSetInstrument
303
}
304
305
// StatefulStateSetVec provides labeled series handles for stateful statesets.
306
type StatefulStateSetVec interface {
307
+ WithHostScope(scope HostScope) StatefulStateSetVec
308
GetWithLabelValues(labelValues ...string) (StateSetInstrument, error)
309
WithLabelValues(labelValues ...string) StateSetInstrument
310
}
src/go/pkg/metrix/measureset.go
+92
-52
@@ -6,18 +6,21 @@ const MeasureSetFieldLabel = "measure_field"
6
7
// stagedMeasureSet holds one in-cycle MeasureSet sample for a single series identity.
8
type stagedMeasureSet struct {
9
- key string
10
- name string
11
- labels []Label
12
- labelsKey string
13
- desc *instrumentDescriptor
14
- values []SampleValue
9
+ key string
10
+ name string
11
+ hostScopeKey string
12
+ hostScope HostScope
13
+ labels []Label
14
+ labelsKey string
15
+ desc *instrumentDescriptor
16
+ values []SampleValue
17
}
18
19
// snapshotMeasureSetGaugeInstrument writes sampled MeasureSet gauge points.
20
type snapshotMeasureSetGaugeInstrument struct {
21
backend meterBackend
22
desc *instrumentDescriptor
23
+ scope HostScope
24
base []LabelSet
25
}
26
@@ -25,6 +28,7 @@ type snapshotMeasureSetGaugeInstrument struct {
28
type snapshotMeasureSetCounterInstrument struct {
29
backend meterBackend
30
desc *instrumentDescriptor
31
+ scope HostScope
32
base []LabelSet
33
}
34
@@ -32,6 +36,7 @@ type snapshotMeasureSetCounterInstrument struct {
36
type statefulMeasureSetGaugeInstrument struct {
37
backend meterBackend
38
desc *instrumentDescriptor
39
+ scope HostScope
40
base []LabelSet
41
}
42
@@ -39,6 +44,7 @@ type statefulMeasureSetGaugeInstrument struct {
44
type statefulMeasureSetCounterInstrument struct {
45
backend meterBackend
46
desc *instrumentDescriptor
47
+ scope HostScope
48
base []LabelSet
49
}
50
@@ -58,6 +64,7 @@ func (m *snapshotMeter) MeasureSetGauge(name string, opts ...InstrumentOption) S
64
return &snapshotMeasureSetGaugeInstrument{
65
backend: m.backend,
66
desc: desc,
67
+ scope: m.scope,
68
base: appendLabelSets(m.sets, nil),
69
}
70
}
@@ -71,6 +78,7 @@ func (m *snapshotMeter) MeasureSetCounter(name string, opts ...InstrumentOption)
78
return &snapshotMeasureSetCounterInstrument{
79
backend: m.backend,
80
desc: desc,
81
+ scope: m.scope,
82
base: appendLabelSets(m.sets, nil),
83
}
84
}
@@ -84,6 +92,7 @@ func (m *statefulMeter) MeasureSetGauge(name string, opts ...InstrumentOption) S
92
return &statefulMeasureSetGaugeInstrument{
93
backend: m.backend,
94
desc: desc,
95
+ scope: m.scope,
96
base: appendLabelSets(m.sets, nil),
97
}
98
}
@@ -97,12 +106,13 @@ func (m *statefulMeter) MeasureSetCounter(name string, opts ...InstrumentOption)
106
return &statefulMeasureSetCounterInstrument{
107
backend: m.backend,
108
desc: desc,
109
+ scope: m.scope,
110
base: appendLabelSets(m.sets, nil),
111
}
112
}
113
114
func (m *snapshotMeasureSetGaugeInstrument) ObservePoint(p MeasureSetPoint, labels ...LabelSet) {
105
- m.backend.recordMeasureSetGaugeObservePoint(m.desc, p, appendLabelSets(m.base, labels))
115
+ m.backend.recordMeasureSetGaugeObservePoint(m.desc, m.scope, p, appendLabelSets(m.base, labels))
116
}
117
118
func (m *snapshotMeasureSetGaugeInstrument) ObserveFields(fields map[string]SampleValue, labels ...LabelSet) {
@@ -110,7 +120,7 @@ func (m *snapshotMeasureSetGaugeInstrument) ObserveFields(fields map[string]Samp
120
}
121
122
func (m *snapshotMeasureSetCounterInstrument) ObserveTotalPoint(p MeasureSetPoint, labels ...LabelSet) {
113
- m.backend.recordMeasureSetCounterObserveTotalPoint(m.desc, p, appendLabelSets(m.base, labels))
123
+ m.backend.recordMeasureSetCounterObserveTotalPoint(m.desc, m.scope, p, appendLabelSets(m.base, labels))
124
}
125
126
func (m *snapshotMeasureSetCounterInstrument) ObserveTotalFields(fields map[string]SampleValue, labels ...LabelSet) {
@@ -118,7 +128,7 @@ func (m *snapshotMeasureSetCounterInstrument) ObserveTotalFields(fields map[stri
128
}
129
130
func (m *statefulMeasureSetGaugeInstrument) SetPoint(p MeasureSetPoint, labels ...LabelSet) {
121
- m.backend.recordMeasureSetGaugeSetPoint(m.desc, p, appendLabelSets(m.base, labels))
131
+ m.backend.recordMeasureSetGaugeSetPoint(m.desc, m.scope, p, appendLabelSets(m.base, labels))
132
}
133
134
func (m *statefulMeasureSetGaugeInstrument) SetFields(fields map[string]SampleValue, labels ...LabelSet) {
@@ -126,11 +136,11 @@ func (m *statefulMeasureSetGaugeInstrument) SetFields(fields map[string]SampleVa
136
}
137
138
func (m *statefulMeasureSetGaugeInstrument) SetField(field string, value SampleValue, labels ...LabelSet) {
129
- m.backend.recordMeasureSetGaugeSetField(m.desc, field, value, appendLabelSets(m.base, labels))
139
+ m.backend.recordMeasureSetGaugeSetField(m.desc, m.scope, field, value, appendLabelSets(m.base, labels))
140
}
141
142
func (m *statefulMeasureSetGaugeInstrument) AddPoint(delta MeasureSetPoint, labels ...LabelSet) {
133
- m.backend.recordMeasureSetGaugeAddPoint(m.desc, delta, appendLabelSets(m.base, labels))
143
+ m.backend.recordMeasureSetGaugeAddPoint(m.desc, m.scope, delta, appendLabelSets(m.base, labels))
144
}
145
146
func (m *statefulMeasureSetGaugeInstrument) AddFields(delta map[string]SampleValue, labels ...LabelSet) {
@@ -142,7 +152,7 @@ func (m *statefulMeasureSetGaugeInstrument) AddField(field string, delta SampleV
152
}
153
154
func (m *statefulMeasureSetCounterInstrument) AddPoint(delta MeasureSetPoint, labels ...LabelSet) {
145
- m.backend.recordMeasureSetCounterAddPoint(m.desc, delta, appendLabelSets(m.base, labels))
155
+ m.backend.recordMeasureSetCounterAddPoint(m.desc, m.scope, delta, appendLabelSets(m.base, labels))
156
}
157
158
func (m *statefulMeasureSetCounterInstrument) AddFields(delta map[string]SampleValue, labels ...LabelSet) {
@@ -222,11 +232,11 @@ func normalizeMeasureSetCounterDelta(delta MeasureSetPoint, schema *measureSetSc
232
return values
233
}
234
225
-func (c *storeCore) recordMeasureSetGaugeObservePoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet) {
226
- c.recordMeasureSetGaugeSetPoint(desc, point, sets)
235
+func (c *storeCore) recordMeasureSetGaugeObservePoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet) {
236
+ c.recordMeasureSetGaugeSetPoint(desc, scope, point, sets)
237
}
238
229
-func (c *storeCore) recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet) {
239
+func (c *storeCore) recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet) {
240
schema := desc.measureSet
241
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
242
panic(errMeasureSetSchema)
@@ -248,23 +258,29 @@ func (c *storeCore) recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, po
258
if labelsContainKey(labels, MeasureSetFieldLabel) {
259
panic(errMeasureSetLabelKey)
260
}
261
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
262
+ if !ok {
263
+ return
264
+ }
265
252
- key := makeSeriesKey(desc.name, labelsKey)
266
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
267
entry, ok := c.active.measureSetGauges[key]
268
if !ok {
269
entry = &stagedMeasureSet{
256
- key: key,
257
- name: desc.name,
258
- labels: labels,
259
- labelsKey: labelsKey,
260
- desc: desc,
270
+ key: key,
271
+ name: desc.name,
272
+ hostScopeKey: scope.ScopeKey,
273
+ hostScope: scope,
274
+ labels: labels,
275
+ labelsKey: labelsKey,
276
+ desc: desc,
277
}
278
c.active.measureSetGauges[key] = entry
279
}
280
entry.values = append(entry.values[:0], values...)
281
}
282
267
-func (c *storeCore) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet) {
283
+func (c *storeCore) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet) {
284
schema := desc.measureSet
285
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
286
panic(errMeasureSetSchema)
@@ -286,8 +302,12 @@ func (c *storeCore) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, de
302
if labelsContainKey(labels, MeasureSetFieldLabel) {
303
panic(errMeasureSetLabelKey)
304
}
305
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
306
+ if !ok {
307
+ return
308
+ }
309
290
- key := makeSeriesKey(desc.name, labelsKey)
310
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
311
entry, ok := c.active.measureSetGauges[key]
312
if !ok {
313
baseline := make([]SampleValue, len(schema.fields))
@@ -298,12 +318,14 @@ func (c *storeCore) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, de
318
}
319
}
320
entry = &stagedMeasureSet{
301
- key: key,
302
- name: desc.name,
303
- labels: labels,
304
- labelsKey: labelsKey,
305
- desc: desc,
306
- values: baseline,
321
+ key: key,
322
+ name: desc.name,
323
+ hostScopeKey: scope.ScopeKey,
324
+ hostScope: scope,
325
+ labels: labels,
326
+ labelsKey: labelsKey,
327
+ desc: desc,
328
+ values: baseline,
329
}
330
c.active.measureSetGauges[key] = entry
331
}
@@ -312,7 +334,7 @@ func (c *storeCore) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, de
334
}
335
}
336
315
-func (c *storeCore) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, field string, value SampleValue, sets []LabelSet) {
337
+func (c *storeCore) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, scope HostScope, field string, value SampleValue, sets []LabelSet) {
338
schema := desc.measureSet
339
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
340
panic(errMeasureSetSchema)
@@ -335,8 +357,12 @@ func (c *storeCore) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, fi
357
if labelsContainKey(labels, MeasureSetFieldLabel) {
358
panic(errMeasureSetLabelKey)
359
}
360
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
361
+ if !ok {
362
+ return
363
+ }
364
339
- key := makeSeriesKey(desc.name, labelsKey)
365
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
366
entry, ok := c.active.measureSetGauges[key]
367
if !ok {
368
baseline := make([]SampleValue, len(schema.fields))
@@ -347,12 +373,14 @@ func (c *storeCore) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, fi
373
}
374
}
375
entry = &stagedMeasureSet{
350
- key: key,
351
- name: desc.name,
352
- labels: labels,
353
- labelsKey: labelsKey,
354
- desc: desc,
355
- values: baseline,
376
+ key: key,
377
+ name: desc.name,
378
+ hostScopeKey: scope.ScopeKey,
379
+ hostScope: scope,
380
+ labels: labels,
381
+ labelsKey: labelsKey,
382
+ desc: desc,
383
+ values: baseline,
384
}
385
c.active.measureSetGauges[key] = entry
386
} else if len(entry.values) == 0 {
@@ -361,7 +389,7 @@ func (c *storeCore) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, fi
389
entry.values[fieldIndex] = value
390
}
391
364
-func (c *storeCore) recordMeasureSetCounterObserveTotalPoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet) {
392
+func (c *storeCore) recordMeasureSetCounterObserveTotalPoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet) {
393
schema := desc.measureSet
394
if schema == nil || schema.semantics != MeasureSetSemanticsCounter {
395
panic(errMeasureSetSchema)
@@ -383,23 +411,29 @@ func (c *storeCore) recordMeasureSetCounterObserveTotalPoint(desc *instrumentDes
411
if labelsContainKey(labels, MeasureSetFieldLabel) {
412
panic(errMeasureSetLabelKey)
413
}
414
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
415
+ if !ok {
416
+ return
417
+ }
418
387
- key := makeSeriesKey(desc.name, labelsKey)
419
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
420
entry, ok := c.active.measureSetCounters[key]
421
if !ok {
422
entry = &stagedMeasureSet{
391
- key: key,
392
- name: desc.name,
393
- labels: labels,
394
- labelsKey: labelsKey,
395
- desc: desc,
423
+ key: key,
424
+ name: desc.name,
425
+ hostScopeKey: scope.ScopeKey,
426
+ hostScope: scope,
427
+ labels: labels,
428
+ labelsKey: labelsKey,
429
+ desc: desc,
430
}
431
c.active.measureSetCounters[key] = entry
432
}
433
entry.values = append(entry.values[:0], values...)
434
}
435
402
-func (c *storeCore) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet) {
436
+func (c *storeCore) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet) {
437
schema := desc.measureSet
438
if schema == nil || schema.semantics != MeasureSetSemanticsCounter {
439
panic(errMeasureSetSchema)
@@ -421,8 +455,12 @@ func (c *storeCore) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor,
455
if labelsContainKey(labels, MeasureSetFieldLabel) {
456
panic(errMeasureSetLabelKey)
457
}
458
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
459
+ if !ok {
460
+ return
461
+ }
462
425
- key := makeSeriesKey(desc.name, labelsKey)
463
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
464
entry, ok := c.active.measureSetCounters[key]
465
if !ok {
466
baseline := make([]SampleValue, len(schema.fields))
@@ -433,12 +471,14 @@ func (c *storeCore) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor,
471
}
472
}
473
entry = &stagedMeasureSet{
436
- key: key,
437
- name: desc.name,
438
- labels: labels,
439
- labelsKey: labelsKey,
440
- desc: desc,
441
- values: baseline,
474
+ key: key,
475
+ name: desc.name,
476
+ hostScopeKey: scope.ScopeKey,
477
+ hostScope: scope,
478
+ labels: labels,
479
+ labelsKey: labelsKey,
480
+ desc: desc,
481
+ values: baseline,
482
}
483
c.active.measureSetCounters[key] = entry
484
}
src/go/pkg/metrix/meter.go
+36
-2
@@ -9,6 +9,7 @@ type writeView struct {
9
type snapshotMeter struct {
10
backend meterBackend
11
prefix string
12
+ scope HostScope
13
sets []LabelSet
14
}
15
@@ -20,6 +21,7 @@ type snapshotVecMeter struct {
21
type statefulMeter struct {
22
backend meterBackend
23
prefix string
24
+ scope HostScope
25
sets []LabelSet
26
}
27
@@ -43,13 +45,22 @@ func (m *snapshotMeter) WithLabels(labels ...Label) SnapshotMeter {
45
return m.WithLabelSet(set)
46
}
47
48
+func (m *snapshotMeter) WithHostScope(scope HostScope) SnapshotMeter {
49
+ return &snapshotMeter{
50
+ backend: m.backend,
51
+ prefix: m.prefix,
52
+ scope: mustNormalizeHostScope(scope),
53
+ sets: appendLabelSets(m.sets, nil),
54
+ }
55
+}
56
+
57
func (m *snapshotMeter) WithLabelSet(labels ...LabelSet) SnapshotMeter {
58
for _, ls := range labels {
59
if ls.set == nil || ls.set.owner != m.backend {
60
panic(errForeignLabelSet)
61
}
62
}
52
- return &snapshotMeter{backend: m.backend, prefix: m.prefix, sets: appendLabelSets(m.sets, labels)}
63
+ return &snapshotMeter{backend: m.backend, prefix: m.prefix, scope: m.scope, sets: appendLabelSets(m.sets, labels)}
64
}
65
66
func (m *snapshotMeter) Vec(labelKeys ...string) SnapshotVecMeter {
@@ -68,13 +79,22 @@ func (m *statefulMeter) WithLabels(labels ...Label) StatefulMeter {
79
return m.WithLabelSet(set)
80
}
81
82
+func (m *statefulMeter) WithHostScope(scope HostScope) StatefulMeter {
83
+ return &statefulMeter{
84
+ backend: m.backend,
85
+ prefix: m.prefix,
86
+ scope: mustNormalizeHostScope(scope),
87
+ sets: appendLabelSets(m.sets, nil),
88
+ }
89
+}
90
+
91
func (m *statefulMeter) WithLabelSet(labels ...LabelSet) StatefulMeter {
92
for _, ls := range labels {
93
if ls.set == nil || ls.set.owner != m.backend {
94
panic(errForeignLabelSet)
95
}
96
}
77
- return &statefulMeter{backend: m.backend, prefix: m.prefix, sets: appendLabelSets(m.sets, labels)}
97
+ return &statefulMeter{backend: m.backend, prefix: m.prefix, scope: m.scope, sets: appendLabelSets(m.sets, labels)}
98
}
99
100
func (m *statefulMeter) Vec(labelKeys ...string) StatefulVecMeter {
@@ -92,6 +112,13 @@ func (m *snapshotVecMeter) Gauge(name string, opts ...InstrumentOption) Snapshot
112
return m.meter.GaugeVec(name, m.labelKeys, opts...)
113
}
114
115
+func (m *snapshotVecMeter) WithHostScope(scope HostScope) SnapshotVecMeter {
116
+ return &snapshotVecMeter{
117
+ meter: m.meter.WithHostScope(scope).(*snapshotMeter),
118
+ labelKeys: append([]string(nil), m.labelKeys...),
119
+ }
120
+}
121
+
122
func (m *snapshotVecMeter) Counter(name string, opts ...InstrumentOption) SnapshotCounterVec {
123
return m.meter.CounterVec(name, m.labelKeys, opts...)
124
}
@@ -120,6 +147,13 @@ func (m *statefulVecMeter) Gauge(name string, opts ...InstrumentOption) Stateful
147
return m.meter.GaugeVec(name, m.labelKeys, opts...)
148
}
149
150
+func (m *statefulVecMeter) WithHostScope(scope HostScope) StatefulVecMeter {
151
+ return &statefulVecMeter{
152
+ meter: m.meter.WithHostScope(scope).(*statefulMeter),
153
+ labelKeys: append([]string(nil), m.labelKeys...),
154
+ }
155
+}
156
+
157
func (m *statefulVecMeter) Counter(name string, opts ...InstrumentOption) StatefulCounterVec {
158
return m.meter.CounterVec(name, m.labelKeys, opts...)
159
}
src/go/pkg/metrix/read_options.go
+11
-2
@@ -14,8 +14,9 @@ func (f readOptionFunc) applyRead(cfg *readConfig) {
14
}
15
16
type readConfig struct {
17
- raw bool
18
- flatten bool
17
+ raw bool
18
+ flatten bool
19
+ hostScopeKey string
20
}
21
22
func resolveReadConfig(opts ...ReadOption) readConfig {
@@ -43,3 +44,11 @@ func ReadFlatten() ReadOption {
44
cfg.flatten = true
45
})
46
}
47
+
48
+// ReadHostScope filters the reader to one host scope. The empty key is the
49
+// default scope and matches unscoped writes.
50
+func ReadHostScope(scopeKey string) ReadOption {
51
+ return readOptionFunc(func(cfg *readConfig) {
52
+ cfg.hostScopeKey = scopeKey
53
+ })
54
+}
src/go/pkg/metrix/reader.go
+85
-54
@@ -5,18 +5,20 @@ package metrix
5
import (
6
"maps"
7
"math"
8
+ "slices"
9
"sort"
10
"sync"
11
)
12
13
type storeReader struct {
13
- snap *readSnapshot
14
- raw bool // true => ReadRaw semantics (no freshness filtering)
15
- flattened bool
16
- seriesOnce sync.Once
17
- series map[string]*committedSeries
18
- indexOnce sync.Once
19
- index map[string][]*committedSeries
14
+ snap *readSnapshot
15
+ raw bool // true => ReadRaw semantics (no freshness filtering)
16
+ flattened bool
17
+ hostScopeKey string
18
+ seriesOnce sync.Once
19
+ series map[string]*committedSeries
20
+ indexOnce sync.Once
21
+ index map[string][]*committedSeries
22
}
23
24
type familyView struct {
@@ -188,6 +190,16 @@ func (r *storeReader) CollectMeta() CollectMeta {
190
return r.snap.collectMeta
191
}
192
193
+func (r *storeReader) HostScopes() []HostScope {
194
+ // Scope discovery intentionally ignores this reader's active scope filter so
195
+ // jobruntime can enumerate all host partitions from one snapshot.
196
+ scopes := make(map[string]HostScope)
197
+ for _, s := range r.seriesView() {
198
+ scopes[s.hostScopeKey] = cloneHostScope(s.hostScope)
199
+ }
200
+ return sortedHostScopes(scopes)
201
+}
202
+
203
func flattenSnapshot(src *readSnapshot) *readSnapshot {
204
series := snapshotSeriesView(src)
205
dst := &readSnapshot{
@@ -242,14 +254,16 @@ func appendFlattenedHistogramSeries(dst *readSnapshot, src *committedSeries) {
254
}
255
256
name := src.name + "_bucket"
245
- key := makeSeriesKey(name, labelsKey)
257
+ key := makeSeriesKey(src.hostScopeKey, name, labelsKey)
258
dst.series[key] = &committedSeries{
247
- id: SeriesID(key),
248
- hash64: seriesIDHash(SeriesID(key)),
249
- key: key,
250
- name: name,
251
- labels: labels,
252
- labelsKey: labelsKey,
259
+ id: SeriesID(key),
260
+ hash64: seriesIDHash(SeriesID(key)),
261
+ key: key,
262
+ name: name,
263
+ hostScopeKey: src.hostScopeKey,
264
+ hostScope: cloneHostScope(src.hostScope),
265
+ labels: labels,
266
+ labelsKey: labelsKey,
267
desc: &instrumentDescriptor{
268
name: name,
269
kind: kindCounter,
@@ -276,14 +290,16 @@ func appendFlattenedHistogramSeries(dst *readSnapshot, src *committedSeries) {
290
infLabels, infLabelsKey, err := canonicalizeLabels(infMap)
291
if err == nil {
292
infName := src.name + "_bucket"
279
- infKey := makeSeriesKey(infName, infLabelsKey)
293
+ infKey := makeSeriesKey(src.hostScopeKey, infName, infLabelsKey)
294
dst.series[infKey] = &committedSeries{
281
- id: SeriesID(infKey),
282
- hash64: seriesIDHash(SeriesID(infKey)),
283
- key: infKey,
284
- name: infName,
285
- labels: infLabels,
286
- labelsKey: infLabelsKey,
295
+ id: SeriesID(infKey),
296
+ hash64: seriesIDHash(SeriesID(infKey)),
297
+ key: infKey,
298
+ name: infName,
299
+ hostScopeKey: src.hostScopeKey,
300
+ hostScope: cloneHostScope(src.hostScope),
301
+ labels: infLabels,
302
+ labelsKey: infLabelsKey,
303
desc: &instrumentDescriptor{
304
name: infName,
305
kind: kindCounter,
@@ -304,6 +320,7 @@ func appendFlattenedHistogramSeries(dst *readSnapshot, src *committedSeries) {
320
321
appendFlattenedHistogramScalar(
322
dst,
323
+ src,
324
src.name+"_count",
325
src.labels,
326
src.histogramCount,
@@ -312,6 +329,7 @@ func appendFlattenedHistogramSeries(dst *readSnapshot, src *committedSeries) {
329
)
330
appendFlattenedHistogramScalar(
331
dst,
332
+ src,
333
src.name+"_sum",
334
src.labels,
335
src.histogramSum,
@@ -320,7 +338,7 @@ func appendFlattenedHistogramSeries(dst *readSnapshot, src *committedSeries) {
338
)
339
}
340
323
-func appendFlattenedHistogramScalar(dst *readSnapshot, name string, labels []Label, value SampleValue, meta SeriesMeta, desc *instrumentDescriptor) {
341
+func appendFlattenedHistogramScalar(dst *readSnapshot, src *committedSeries, name string, labels []Label, value SampleValue, meta SeriesMeta, desc *instrumentDescriptor) {
342
labelsMap := make(map[string]string, len(labels))
343
for _, lbl := range labels {
344
labelsMap[lbl.Key] = lbl.Value
@@ -329,14 +347,16 @@ func appendFlattenedHistogramScalar(dst *readSnapshot, name string, labels []Lab
347
if err != nil {
348
return
349
}
332
- key := makeSeriesKey(name, labelsKey)
350
+ key := makeSeriesKey(src.hostScopeKey, name, labelsKey)
351
dst.series[key] = &committedSeries{
334
- id: SeriesID(key),
335
- hash64: seriesIDHash(SeriesID(key)),
336
- key: key,
337
- name: name,
338
- labels: items,
339
- labelsKey: labelsKey,
352
+ id: SeriesID(key),
353
+ hash64: seriesIDHash(SeriesID(key)),
354
+ key: key,
355
+ name: name,
356
+ hostScopeKey: src.hostScopeKey,
357
+ hostScope: cloneHostScope(src.hostScope),
358
+ labels: items,
359
+ labelsKey: labelsKey,
360
desc: &instrumentDescriptor{
361
name: name,
362
kind: kindCounter,
@@ -353,6 +373,7 @@ func appendFlattenedHistogramScalar(dst *readSnapshot, name string, labels []Lab
373
func appendFlattenedSummarySeries(dst *readSnapshot, src *committedSeries) {
374
appendFlattenedHistogramScalar(
375
dst,
376
+ src,
377
src.name+"_count",
378
src.labels,
379
src.summaryCount,
@@ -361,6 +382,7 @@ func appendFlattenedSummarySeries(dst *readSnapshot, src *committedSeries) {
382
)
383
appendFlattenedHistogramScalar(
384
dst,
385
+ src,
386
src.name+"_sum",
387
src.labels,
388
src.summarySum,
@@ -387,14 +409,16 @@ func appendFlattenedSummarySeries(dst *readSnapshot, src *committedSeries) {
409
if err != nil {
410
continue
411
}
390
- key := makeSeriesKey(src.name, labelsKey)
412
+ key := makeSeriesKey(src.hostScopeKey, src.name, labelsKey)
413
dst.series[key] = &committedSeries{
392
- id: SeriesID(key),
393
- hash64: seriesIDHash(SeriesID(key)),
394
- key: key,
395
- name: src.name,
396
- labels: labels,
397
- labelsKey: labelsKey,
414
+ id: SeriesID(key),
415
+ hash64: seriesIDHash(SeriesID(key)),
416
+ key: key,
417
+ name: src.name,
418
+ hostScopeKey: src.hostScopeKey,
419
+ hostScope: cloneHostScope(src.hostScope),
420
+ labels: labels,
421
+ labelsKey: labelsKey,
422
desc: &instrumentDescriptor{
423
name: src.name,
424
kind: kindGauge,
@@ -437,14 +461,16 @@ func appendFlattenedStateSetSeries(dst *readSnapshot, src *committedSeries) {
461
continue
462
}
463
440
- key := makeSeriesKey(src.name, labelsKey)
464
+ key := makeSeriesKey(src.hostScopeKey, src.name, labelsKey)
465
dst.series[key] = &committedSeries{
442
- id: SeriesID(key),
443
- hash64: seriesIDHash(SeriesID(key)),
444
- key: key,
445
- name: src.name,
446
- labels: labels,
447
- labelsKey: labelsKey,
466
+ id: SeriesID(key),
467
+ hash64: seriesIDHash(SeriesID(key)),
468
+ key: key,
469
+ name: src.name,
470
+ hostScopeKey: src.hostScopeKey,
471
+ hostScope: cloneHostScope(src.hostScope),
472
+ labels: labels,
473
+ labelsKey: labelsKey,
474
desc: &instrumentDescriptor{
475
name: src.name,
476
kind: kindGauge,
@@ -490,17 +516,19 @@ func appendFlattenedMeasureSetSeries(dst *readSnapshot, src *committedSeries) {
516
}
517
518
name := src.name + "_" + field.Name
493
- key := makeSeriesKey(name, labelsKey)
519
+ key := makeSeriesKey(src.hostScopeKey, name, labelsKey)
520
meta := src.desc.meta
521
meta.Float = field.Float
522
523
series := &committedSeries{
498
- id: SeriesID(key),
499
- hash64: seriesIDHash(SeriesID(key)),
500
- key: key,
501
- name: name,
502
- labels: labels,
503
- labelsKey: labelsKey,
524
+ id: SeriesID(key),
525
+ hash64: seriesIDHash(SeriesID(key)),
526
+ key: key,
527
+ name: name,
528
+ hostScopeKey: src.hostScopeKey,
529
+ hostScope: cloneHostScope(src.hostScope),
530
+ labels: labels,
531
+ labelsKey: labelsKey,
532
desc: &instrumentDescriptor{
533
name: name,
534
kind: descKind,
@@ -532,10 +560,10 @@ func appendFlattenedMeasureSetSeries(dst *readSnapshot, src *committedSeries) {
560
561
func (r *storeReader) Family(name string) (FamilyView, bool) {
562
index := r.byNameIndex()
535
- if len(index[name]) == 0 {
536
- return nil, false
563
+ if slices.ContainsFunc(index[name], r.visible) {
564
+ return familyView{name: name, reader: r}, true
565
}
538
- return familyView{name: name, reader: r}, true
566
+ return nil, false
567
}
568
569
func (r *storeReader) ForEachByName(name string, fn func(labels LabelView, v SampleValue)) {
@@ -609,7 +637,7 @@ func (r *storeReader) lookup(name string, labels Labels) (*committedSeries, bool
637
_ = items
638
return nil, false
639
}
612
- key := makeSeriesKey(name, labelsKey)
640
+ key := makeSeriesKey(r.hostScopeKey, name, labelsKey)
641
return lookupSnapshotSeries(r.snap, key)
642
}
643
@@ -667,6 +695,9 @@ func materializeRuntimeSeries(snap *readSnapshot) map[string]*committedSeries {
695
696
// visible applies freshness policy for Read(); Read(ReadRaw()) bypasses it.
697
func (r *storeReader) visible(s *committedSeries) bool {
698
+ if s.hostScopeKey != r.hostScopeKey {
699
+ return false
700
+ }
701
if r.raw {
702
return true
703
}
src/go/pkg/metrix/runtime_store.go
+57
-45
@@ -75,7 +75,7 @@ func (s *runtimeStoreView) Read(opts ...ReadOption) Reader {
75
if cfg.flatten {
76
snap = flattenSnapshot(snap)
77
}
78
- return &storeReader{snap: snap, raw: cfg.raw, flattened: cfg.flatten}
78
+ return &storeReader{snap: snap, raw: cfg.raw, flattened: cfg.flatten, hostScopeKey: cfg.hostScopeKey}
79
}
80
81
func (s *runtimeStoreView) Write() RuntimeWriter {
@@ -118,43 +118,45 @@ func (r *runtimeStoreBackend) registerInstrument(name string, kind metricKind, m
118
return desc, nil
119
}
120
121
-func (r *runtimeStoreBackend) recordGaugeSet(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
121
+func (r *runtimeStoreBackend) recordGaugeSet(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
122
mustFiniteSample(value)
123
124
labels, labelsKey, err := labelsFromSet(sets, r)
125
if err != nil {
126
panic(err)
127
}
128
- key := makeSeriesKey(desc.name, labelsKey)
128
+ scope = mustNormalizeHostScope(scope)
129
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
130
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
130
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
131
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
132
series.value = value
133
series.meta.LastSeenSuccessSeq = seq
134
series.runtimeLastSeenUnixNano = nowUnixNano
135
})
136
}
137
137
-func (r *runtimeStoreBackend) recordGaugeAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet) {
138
+func (r *runtimeStoreBackend) recordGaugeAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet) {
139
mustFiniteSample(delta)
140
141
labels, labelsKey, err := labelsFromSet(sets, r)
142
if err != nil {
143
panic(err)
144
}
144
- key := makeSeriesKey(desc.name, labelsKey)
145
+ scope = mustNormalizeHostScope(scope)
146
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
147
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
146
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
148
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
149
series.value += delta
150
series.meta.LastSeenSuccessSeq = seq
151
series.runtimeLastSeenUnixNano = nowUnixNano
152
})
153
}
154
153
-func (r *runtimeStoreBackend) recordCounterObserveTotal(_ *instrumentDescriptor, _ SampleValue, _ []LabelSet) {
155
+func (r *runtimeStoreBackend) recordCounterObserveTotal(_ *instrumentDescriptor, _ HostScope, _ SampleValue, _ []LabelSet) {
156
panic(errRuntimeSnapshotWrite)
157
}
158
157
-func (r *runtimeStoreBackend) recordCounterAdd(desc *instrumentDescriptor, delta SampleValue, sets []LabelSet) {
159
+func (r *runtimeStoreBackend) recordCounterAdd(desc *instrumentDescriptor, scope HostScope, delta SampleValue, sets []LabelSet) {
160
mustFiniteSample(delta)
161
162
if delta < 0 {
@@ -165,9 +167,10 @@ func (r *runtimeStoreBackend) recordCounterAdd(desc *instrumentDescriptor, delta
167
if err != nil {
168
panic(err)
169
}
168
- key := makeSeriesKey(desc.name, labelsKey)
170
+ scope = mustNormalizeHostScope(scope)
171
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
172
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
170
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
173
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
174
175
hadCurrent := series.desc != nil && series.desc.kind == kindCounter && series.counterCurrentSeq > 0
176
if hadCurrent {
@@ -189,11 +192,11 @@ func (r *runtimeStoreBackend) recordCounterAdd(desc *instrumentDescriptor, delta
192
})
193
}
194
192
-func (r *runtimeStoreBackend) recordHistogramObservePoint(_ *instrumentDescriptor, _ HistogramPoint, _ []LabelSet) {
195
+func (r *runtimeStoreBackend) recordHistogramObservePoint(_ *instrumentDescriptor, _ HostScope, _ HistogramPoint, _ []LabelSet) {
196
panic(errRuntimeSnapshotWrite)
197
}
198
196
-func (r *runtimeStoreBackend) recordHistogramObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
199
+func (r *runtimeStoreBackend) recordHistogramObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
200
mustFiniteSample(value)
201
202
schema := desc.histogram
@@ -208,10 +211,11 @@ func (r *runtimeStoreBackend) recordHistogramObserve(desc *instrumentDescriptor,
211
if labelsContainKey(labels, HistogramBucketLabel) {
212
panic(errHistogramLabelKey)
213
}
214
+ scope = mustNormalizeHostScope(scope)
215
212
- key := makeSeriesKey(desc.name, labelsKey)
216
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
217
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
214
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
218
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
219
220
if series.desc.histogram == nil || !equalHistogramBounds(series.desc.histogram.bounds, schema.bounds) {
221
panic("metrix: histogram schema drift detected")
@@ -233,11 +237,11 @@ func (r *runtimeStoreBackend) recordHistogramObserve(desc *instrumentDescriptor,
237
})
238
}
239
236
-func (r *runtimeStoreBackend) recordSummaryObservePoint(_ *instrumentDescriptor, _ SummaryPoint, _ []LabelSet) {
240
+func (r *runtimeStoreBackend) recordSummaryObservePoint(_ *instrumentDescriptor, _ HostScope, _ SummaryPoint, _ []LabelSet) {
241
panic(errRuntimeSnapshotWrite)
242
}
243
240
-func (r *runtimeStoreBackend) recordSummaryObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
244
+func (r *runtimeStoreBackend) recordSummaryObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
245
mustFiniteSample(value)
246
247
labels, labelsKey, err := labelsFromSet(sets, r)
@@ -247,10 +251,11 @@ func (r *runtimeStoreBackend) recordSummaryObserve(desc *instrumentDescriptor, v
251
if labelsContainKey(labels, SummaryQuantileLabel) {
252
panic(errSummaryLabelKey)
253
}
254
+ scope = mustNormalizeHostScope(scope)
255
251
- key := makeSeriesKey(desc.name, labelsKey)
256
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
257
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
253
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
258
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
259
260
series.summaryCount++
261
series.summarySum += value
@@ -273,7 +278,7 @@ func (r *runtimeStoreBackend) recordSummaryObserve(desc *instrumentDescriptor, v
278
})
279
}
280
276
-func (r *runtimeStoreBackend) recordStateSetObserve(desc *instrumentDescriptor, point StateSetPoint, sets []LabelSet) {
281
+func (r *runtimeStoreBackend) recordStateSetObserve(desc *instrumentDescriptor, scope HostScope, point StateSetPoint, sets []LabelSet) {
282
schema := desc.stateSet
283
if schema == nil {
284
panic(errStateSetSchema)
@@ -286,22 +291,23 @@ func (r *runtimeStoreBackend) recordStateSetObserve(desc *instrumentDescriptor,
291
if labelsContainKey(labels, desc.name) {
292
panic(errStateSetLabelKey)
293
}
294
+ scope = mustNormalizeHostScope(scope)
295
states := normalizeStateSetPoint(point, schema)
296
291
- key := makeSeriesKey(desc.name, labelsKey)
297
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
298
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
293
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
299
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
300
series.stateSetValues = cloneStateMap(states)
301
series.meta.LastSeenSuccessSeq = seq
302
series.runtimeLastSeenUnixNano = nowUnixNano
303
})
304
}
305
300
-func (r *runtimeStoreBackend) recordMeasureSetGaugeObservePoint(_ *instrumentDescriptor, _ MeasureSetPoint, _ []LabelSet) {
306
+func (r *runtimeStoreBackend) recordMeasureSetGaugeObservePoint(_ *instrumentDescriptor, _ HostScope, _ MeasureSetPoint, _ []LabelSet) {
307
panic(errRuntimeSnapshotWrite)
308
}
309
304
-func (r *runtimeStoreBackend) recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, point MeasureSetPoint, sets []LabelSet) {
310
+func (r *runtimeStoreBackend) recordMeasureSetGaugeSetPoint(desc *instrumentDescriptor, scope HostScope, point MeasureSetPoint, sets []LabelSet) {
311
schema := desc.measureSet
312
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
313
panic(errMeasureSetSchema)
@@ -316,16 +322,17 @@ func (r *runtimeStoreBackend) recordMeasureSetGaugeSetPoint(desc *instrumentDesc
322
if labelsContainKey(labels, MeasureSetFieldLabel) {
323
panic(errMeasureSetLabelKey)
324
}
319
- key := makeSeriesKey(desc.name, labelsKey)
325
+ scope = mustNormalizeHostScope(scope)
326
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
327
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
321
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
328
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
329
series.measureSetValues = append(series.measureSetValues[:0], values...)
330
series.meta.LastSeenSuccessSeq = seq
331
series.runtimeLastSeenUnixNano = nowUnixNano
332
})
333
}
334
328
-func (r *runtimeStoreBackend) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet) {
335
+func (r *runtimeStoreBackend) recordMeasureSetGaugeAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet) {
336
schema := desc.measureSet
337
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
338
panic(errMeasureSetSchema)
@@ -340,9 +347,10 @@ func (r *runtimeStoreBackend) recordMeasureSetGaugeAddPoint(desc *instrumentDesc
347
if labelsContainKey(labels, MeasureSetFieldLabel) {
348
panic(errMeasureSetLabelKey)
349
}
343
- key := makeSeriesKey(desc.name, labelsKey)
350
+ scope = mustNormalizeHostScope(scope)
351
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
352
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
345
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
353
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
354
if len(series.measureSetValues) == 0 {
355
series.measureSetValues = make([]SampleValue, len(schema.fields))
356
}
@@ -354,7 +362,7 @@ func (r *runtimeStoreBackend) recordMeasureSetGaugeAddPoint(desc *instrumentDesc
362
})
363
}
364
357
-func (r *runtimeStoreBackend) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, field string, value SampleValue, sets []LabelSet) {
365
+func (r *runtimeStoreBackend) recordMeasureSetGaugeSetField(desc *instrumentDescriptor, scope HostScope, field string, value SampleValue, sets []LabelSet) {
366
schema := desc.measureSet
367
if schema == nil || schema.semantics != MeasureSetSemanticsGauge {
368
panic(errMeasureSetSchema)
@@ -370,9 +378,10 @@ func (r *runtimeStoreBackend) recordMeasureSetGaugeSetField(desc *instrumentDesc
378
if labelsContainKey(labels, MeasureSetFieldLabel) {
379
panic(errMeasureSetLabelKey)
380
}
373
- key := makeSeriesKey(desc.name, labelsKey)
381
+ scope = mustNormalizeHostScope(scope)
382
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
383
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
375
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
384
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
385
if len(series.measureSetValues) == 0 {
386
series.measureSetValues = make([]SampleValue, len(schema.fields))
387
}
@@ -382,11 +391,11 @@ func (r *runtimeStoreBackend) recordMeasureSetGaugeSetField(desc *instrumentDesc
391
})
392
}
393
385
-func (r *runtimeStoreBackend) recordMeasureSetCounterObserveTotalPoint(_ *instrumentDescriptor, _ MeasureSetPoint, _ []LabelSet) {
394
+func (r *runtimeStoreBackend) recordMeasureSetCounterObserveTotalPoint(_ *instrumentDescriptor, _ HostScope, _ MeasureSetPoint, _ []LabelSet) {
395
panic(errRuntimeSnapshotWrite)
396
}
397
389
-func (r *runtimeStoreBackend) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, delta MeasureSetPoint, sets []LabelSet) {
398
+func (r *runtimeStoreBackend) recordMeasureSetCounterAddPoint(desc *instrumentDescriptor, scope HostScope, delta MeasureSetPoint, sets []LabelSet) {
399
schema := desc.measureSet
400
if schema == nil || schema.semantics != MeasureSetSemanticsCounter {
401
panic(errMeasureSetSchema)
@@ -401,9 +410,10 @@ func (r *runtimeStoreBackend) recordMeasureSetCounterAddPoint(desc *instrumentDe
410
if labelsContainKey(labels, MeasureSetFieldLabel) {
411
panic(errMeasureSetLabelKey)
412
}
404
- key := makeSeriesKey(desc.name, labelsKey)
413
+ scope = mustNormalizeHostScope(scope)
414
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
415
r.commitRuntimeWrite(func(old, next *readSnapshot, seq uint64, nowUnixNano int64) {
406
- series := runtimeEnsureSeriesMutable(old, next, key, desc.name, labels, labelsKey, desc)
416
+ series := runtimeEnsureSeriesMutable(old, next, key, desc.name, scope.ScopeKey, scope, labels, labelsKey, desc)
417
if len(series.measureSetValues) == 0 {
418
series.measureSetValues = make([]SampleValue, len(schema.fields))
419
}
@@ -460,7 +470,7 @@ func (r *runtimeStoreBackend) commitRuntimeWrite(apply func(old, next *readSnaps
470
r.core.snapshot.Store(next)
471
}
472
463
-func runtimeEnsureSeriesMutable(old, next *readSnapshot, key, name string, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
473
+func runtimeEnsureSeriesMutable(old, next *readSnapshot, key, name, hostScopeKey string, hostScope HostScope, labels []Label, labelsKey string, desc *instrumentDescriptor) *committedSeries {
474
series := next.series[key]
475
if series != nil {
476
ensureSeriesMeta(series.desc, &series.meta)
@@ -473,14 +483,16 @@ func runtimeEnsureSeriesMutable(old, next *readSnapshot, key, name string, label
483
return series
484
}
485
series = &committedSeries{
476
- id: SeriesID(key),
477
- hash64: seriesIDHash(SeriesID(key)),
478
- key: key,
479
- name: name,
480
- labels: append([]Label(nil), labels...),
481
- labelsKey: labelsKey,
482
- desc: desc,
483
- meta: baseSeriesMeta(desc),
486
+ id: SeriesID(key),
487
+ hash64: seriesIDHash(SeriesID(key)),
488
+ key: key,
489
+ name: name,
490
+ hostScopeKey: hostScopeKey,
491
+ hostScope: cloneHostScope(hostScope),
492
+ labels: append([]Label(nil), labels...),
493
+ labelsKey: labelsKey,
494
+ desc: desc,
495
+ meta: baseSeriesMeta(desc),
496
}
497
next.series[key] = series
498
return series
src/go/pkg/metrix/stateset.go
+27
-15
@@ -6,6 +6,7 @@ package metrix
6
type snapshotStateSetInstrument struct {
7
backend meterBackend
8
desc *instrumentDescriptor
9
+ scope HostScope
10
base []LabelSet
11
}
12
@@ -13,17 +14,20 @@ type snapshotStateSetInstrument struct {
14
type statefulStateSetInstrument struct {
15
backend meterBackend
16
desc *instrumentDescriptor
17
+ scope HostScope
18
base []LabelSet
19
}
20
21
// stagedStateSet holds one in-cycle stateset sample for a single series identity.
22
type stagedStateSet struct {
21
- key string
22
- name string
23
- labels []Label
24
- labelsKey string
25
- desc *instrumentDescriptor
26
- states map[string]bool
23
+ key string
24
+ name string
25
+ hostScopeKey string
26
+ hostScope HostScope
27
+ labels []Label
28
+ labelsKey string
29
+ desc *instrumentDescriptor
30
+ states map[string]bool
31
}
32
33
// StateSet declares or reuses a snapshot stateset under this meter.
@@ -35,6 +39,7 @@ func (m *snapshotMeter) StateSet(name string, opts ...InstrumentOption) StateSet
39
return &snapshotStateSetInstrument{
40
backend: m.backend,
41
desc: desc,
42
+ scope: m.scope,
43
base: appendLabelSets(m.sets, nil),
44
}
45
}
@@ -48,13 +53,14 @@ func (m *statefulMeter) StateSet(name string, opts ...InstrumentOption) StateSet
53
return &statefulStateSetInstrument{
54
backend: m.backend,
55
desc: desc,
56
+ scope: m.scope,
57
base: appendLabelSets(m.sets, nil),
58
}
59
}
60
61
// ObserveStateSet writes a full-state sample for this collect cycle.
62
func (s *snapshotStateSetInstrument) ObserveStateSet(p StateSetPoint, labels ...LabelSet) {
57
- s.backend.recordStateSetObserve(s.desc, p, appendLabelSets(s.base, labels))
63
+ s.backend.recordStateSetObserve(s.desc, s.scope, p, appendLabelSets(s.base, labels))
64
}
65
66
// Enable writes enum/bitset convenience sample with listed active states.
@@ -64,7 +70,7 @@ func (s *snapshotStateSetInstrument) Enable(actives ...string) {
70
71
// ObserveStateSet writes a full-state sample for this collect cycle.
72
func (s *statefulStateSetInstrument) ObserveStateSet(p StateSetPoint, labels ...LabelSet) {
67
- s.backend.recordStateSetObserve(s.desc, p, appendLabelSets(s.base, labels))
73
+ s.backend.recordStateSetObserve(s.desc, s.scope, p, appendLabelSets(s.base, labels))
74
}
75
76
// Enable writes enum/bitset convenience sample with listed active states.
@@ -98,7 +104,7 @@ func stateSetPointFromActives(desc *instrumentDescriptor, actives ...string) Sta
104
}
105
106
// recordStateSetObserve writes one full-state stateset sample into the active frame.
101
-func (c *storeCore) recordStateSetObserve(desc *instrumentDescriptor, point StateSetPoint, sets []LabelSet) {
107
+func (c *storeCore) recordStateSetObserve(desc *instrumentDescriptor, scope HostScope, point StateSetPoint, sets []LabelSet) {
108
c.mu.Lock()
109
defer c.mu.Unlock()
110
@@ -118,18 +124,24 @@ func (c *storeCore) recordStateSetObserve(desc *instrumentDescriptor, point Stat
124
if labelsContainKey(labels, desc.name) {
125
panic(errStateSetLabelKey)
126
}
127
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
128
+ if !ok {
129
+ return
130
+ }
131
132
states := normalizeStateSetPoint(point, schema)
133
124
- key := makeSeriesKey(desc.name, labelsKey)
134
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
135
entry, ok := c.active.stateSet[key]
136
if !ok {
137
entry = &stagedStateSet{
128
- key: key,
129
- name: desc.name,
130
- labels: labels,
131
- labelsKey: labelsKey,
132
- desc: desc,
138
+ key: key,
139
+ name: desc.name,
140
+ hostScopeKey: scope.ScopeKey,
141
+ hostScope: scope,
142
+ labels: labels,
143
+ labelsKey: labelsKey,
144
+ desc: desc,
145
}
146
c.active.stateSet[key] = entry
147
}
src/go/pkg/metrix/summary.go
+40
-21
@@ -18,6 +18,7 @@ const initialSummaryReservoirCapacity = 64
18
type snapshotSummaryInstrument struct {
19
backend meterBackend
20
desc *instrumentDescriptor
21
+ scope HostScope
22
base []LabelSet
23
}
24
@@ -25,16 +26,20 @@ type snapshotSummaryInstrument struct {
26
type statefulSummaryInstrument struct {
27
backend meterBackend
28
desc *instrumentDescriptor
29
+ scope HostScope
30
base []LabelSet
31
}
32
33
// stagedSummary holds one in-cycle summary sample for a single series identity.
34
type stagedSummary struct {
33
- key string
34
- name string
35
- labels []Label
36
- labelsKey string
37
- desc *instrumentDescriptor
35
+ key string
36
+ name string
37
+ hostScopeKey string
38
+ hostScope HostScope
39
+ labels []Label
40
+ labelsKey string
41
+ desc *instrumentDescriptor
42
+
43
count SampleValue
44
sum SampleValue
45
quantileValues []SampleValue
@@ -50,6 +55,7 @@ func (m *snapshotMeter) Summary(name string, opts ...InstrumentOption) SnapshotS
55
return &snapshotSummaryInstrument{
56
backend: m.backend,
57
desc: desc,
58
+ scope: m.scope,
59
base: appendLabelSets(m.sets, nil),
60
}
61
}
@@ -63,22 +69,23 @@ func (m *statefulMeter) Summary(name string, opts ...InstrumentOption) StatefulS
69
return &statefulSummaryInstrument{
70
backend: m.backend,
71
desc: desc,
72
+ scope: m.scope,
73
base: appendLabelSets(m.sets, nil),
74
}
75
}
76
77
// ObservePoint writes one full summary point for this collect cycle.
78
func (s *snapshotSummaryInstrument) ObservePoint(p SummaryPoint, labels ...LabelSet) {
72
- s.backend.recordSummaryObservePoint(s.desc, p, appendLabelSets(s.base, labels))
79
+ s.backend.recordSummaryObservePoint(s.desc, s.scope, p, appendLabelSets(s.base, labels))
80
}
81
82
// Observe adds one sample to a stateful summary for this collect cycle.
83
func (s *statefulSummaryInstrument) Observe(v SampleValue, labels ...LabelSet) {
77
- s.backend.recordSummaryObserve(s.desc, v, appendLabelSets(s.base, labels))
84
+ s.backend.recordSummaryObserve(s.desc, s.scope, v, appendLabelSets(s.base, labels))
85
}
86
87
// recordSummaryObservePoint writes one full summary point into the active frame.
81
-func (c *storeCore) recordSummaryObservePoint(desc *instrumentDescriptor, point SummaryPoint, sets []LabelSet) {
88
+func (c *storeCore) recordSummaryObservePoint(desc *instrumentDescriptor, scope HostScope, point SummaryPoint, sets []LabelSet) {
89
c.mu.Lock()
90
defer c.mu.Unlock()
91
@@ -93,18 +100,24 @@ func (c *storeCore) recordSummaryObservePoint(desc *instrumentDescriptor, point
100
if labelsContainKey(labels, SummaryQuantileLabel) {
101
panic(errSummaryLabelKey)
102
}
103
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
104
+ if !ok {
105
+ return
106
+ }
107
108
count, sum, quantiles := normalizeSummaryPoint(point, desc.summary)
109
99
- key := makeSeriesKey(desc.name, labelsKey)
110
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
111
entry, ok := c.active.summaries[key]
112
if !ok {
113
entry = &stagedSummary{
103
- key: key,
104
- name: desc.name,
105
- labels: labels,
106
- labelsKey: labelsKey,
107
- desc: desc,
114
+ key: key,
115
+ name: desc.name,
116
+ hostScopeKey: scope.ScopeKey,
117
+ hostScope: scope,
118
+ labels: labels,
119
+ labelsKey: labelsKey,
120
+ desc: desc,
121
}
122
c.active.summaries[key] = entry
123
}
@@ -116,7 +129,7 @@ func (c *storeCore) recordSummaryObservePoint(desc *instrumentDescriptor, point
129
}
130
131
// recordSummaryObserve adds one sample to a stateful summary in the active frame.
119
-func (c *storeCore) recordSummaryObserve(desc *instrumentDescriptor, value SampleValue, sets []LabelSet) {
132
+func (c *storeCore) recordSummaryObserve(desc *instrumentDescriptor, scope HostScope, value SampleValue, sets []LabelSet) {
133
mustFiniteSample(value)
134
135
c.mu.Lock()
@@ -133,16 +146,22 @@ func (c *storeCore) recordSummaryObserve(desc *instrumentDescriptor, value Sampl
146
if labelsContainKey(labels, SummaryQuantileLabel) {
147
panic(errSummaryLabelKey)
148
}
149
+ scope, ok := c.prepareHostScopeForWriteLocked(scope)
150
+ if !ok {
151
+ return
152
+ }
153
137
- key := makeSeriesKey(desc.name, labelsKey)
154
+ key := makeSeriesKey(scope.ScopeKey, desc.name, labelsKey)
155
entry, ok := c.active.summaries[key]
156
if !ok {
157
entry = &stagedSummary{
141
- key: key,
142
- name: desc.name,
143
- labels: labels,
144
- labelsKey: labelsKey,
145
- desc: desc,
158
+ key: key,
159
+ name: desc.name,
160
+ hostScopeKey: scope.ScopeKey,
161
+ hostScope: scope,
162
+ labels: labels,
163
+ labelsKey: labelsKey,
164
+ desc: desc,
165
}
166
if desc.window == WindowCumulative {
167
if existing := c.snapshot.Load().series[key]; existing != nil && existing.desc != nil && existing.desc.kind == kindSummary {
src/go/pkg/metrix/vec.go
+142
-32
@@ -21,14 +21,14 @@ type vecCache[T any] struct {
21
mu sync.RWMutex
22
cache map[string]T
23
24
- makeHandle func(base []LabelSet, vecSet LabelSet) T
24
+ makeHandle func(scope HostScope, base []LabelSet, vecSet LabelSet) T
25
}
26
27
func newVecCache[T any](
28
backend meterBackend,
29
base []LabelSet,
30
keys []string,
31
- makeHandle func(base []LabelSet, vecSet LabelSet) T,
31
+ makeHandle func(scope HostScope, base []LabelSet, vecSet LabelSet) T,
32
) *vecCache[T] {
33
return &vecCache[T]{
34
backend: backend,
@@ -39,13 +39,15 @@ func newVecCache[T any](
39
}
40
}
41
42
-func (v *vecCache[T]) get(labelValues ...string) (T, error) {
42
+func (v *vecCache[T]) get(scope HostScope, labelValues ...string) (T, error) {
43
var zero T
44
45
cacheKey, err := vecSeriesCacheKey(v.keys, labelValues)
46
if err != nil {
47
return zero, err
48
}
49
+ scope = mustNormalizeHostScope(scope)
50
+ cacheKey = scopedVecCacheKey(scope.ScopeKey, cacheKey)
51
52
v.mu.RLock()
53
inst, ok := v.cache[cacheKey]
@@ -67,7 +69,7 @@ func (v *vecCache[T]) get(labelValues ...string) (T, error) {
69
return zero, err
70
}
71
70
- inst = v.makeHandle(v.base, vecSet)
72
+ inst = v.makeHandle(scope, v.base, vecSet)
73
v.cache[cacheKey] = inst
74
return inst, nil
75
}
@@ -95,71 +97,85 @@ func mustNormalizeVecLabelKeys(labelKeys []string) []string {
97
// snapshotGaugeVec caches snapshot gauge series handles by vec label values.
98
type snapshotGaugeVec struct {
99
cache *vecCache[*snapshotGaugeInstrument]
100
+ scope HostScope
101
}
102
103
// statefulGaugeVec caches stateful gauge series handles by vec label values.
104
type statefulGaugeVec struct {
105
cache *vecCache[*statefulGaugeInstrument]
106
+ scope HostScope
107
}
108
109
// snapshotCounterVec caches snapshot counter series handles by vec label values.
110
type snapshotCounterVec struct {
111
cache *vecCache[*snapshotCounterInstrument]
112
+ scope HostScope
113
}
114
115
// statefulCounterVec caches stateful counter series handles by vec label values.
116
type statefulCounterVec struct {
117
cache *vecCache[*statefulCounterInstrument]
118
+ scope HostScope
119
}
120
121
// snapshotHistogramVec caches snapshot histogram series handles by vec label values.
122
type snapshotHistogramVec struct {
123
cache *vecCache[*snapshotHistogramInstrument]
124
+ scope HostScope
125
}
126
127
// statefulHistogramVec caches stateful histogram series handles by vec label values.
128
type statefulHistogramVec struct {
129
cache *vecCache[*statefulHistogramInstrument]
130
+ scope HostScope
131
}
132
133
// snapshotSummaryVec caches snapshot summary series handles by vec label values.
134
type snapshotSummaryVec struct {
135
cache *vecCache[*snapshotSummaryInstrument]
136
+ scope HostScope
137
}
138
139
// statefulSummaryVec caches stateful summary series handles by vec label values.
140
type statefulSummaryVec struct {
141
cache *vecCache[*statefulSummaryInstrument]
142
+ scope HostScope
143
}
144
145
// snapshotStateSetVec caches snapshot stateset series handles by vec label values.
146
type snapshotStateSetVec struct {
147
cache *vecCache[*snapshotStateSetInstrument]
148
+ scope HostScope
149
}
150
151
// statefulStateSetVec caches stateful stateset series handles by vec label values.
152
type statefulStateSetVec struct {
153
cache *vecCache[*statefulStateSetInstrument]
154
+ scope HostScope
155
}
156
157
// snapshotMeasureSetGaugeVec caches snapshot MeasureSet gauge series handles by vec label values.
158
type snapshotMeasureSetGaugeVec struct {
159
cache *vecCache[*snapshotMeasureSetGaugeInstrument]
160
+ scope HostScope
161
}
162
163
// snapshotMeasureSetCounterVec caches snapshot MeasureSet counter series handles by vec label values.
164
type snapshotMeasureSetCounterVec struct {
165
cache *vecCache[*snapshotMeasureSetCounterInstrument]
166
+ scope HostScope
167
}
168
169
// statefulMeasureSetGaugeVec caches stateful MeasureSet gauge series handles by vec label values.
170
type statefulMeasureSetGaugeVec struct {
171
cache *vecCache[*statefulMeasureSetGaugeInstrument]
172
+ scope HostScope
173
}
174
175
// statefulMeasureSetCounterVec caches stateful MeasureSet counter series handles by vec label values.
176
type statefulMeasureSetCounterVec struct {
177
cache *vecCache[*statefulMeasureSetCounterInstrument]
178
+ scope HostScope
179
}
180
181
// GaugeVec declares or reuses a snapshot gauge and exposes a label-values lookup API.
@@ -168,10 +184,12 @@ func (m *snapshotMeter) GaugeVec(name string, labelKeys []string, opts ...Instru
184
keys := mustNormalizeVecLabelKeys(labelKeys)
185
base := appendLabelSets(m.sets, nil)
186
return &snapshotGaugeVec{
171
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotGaugeInstrument {
187
+ scope: m.scope,
188
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotGaugeInstrument {
189
return &snapshotGaugeInstrument{
190
backend: m.backend,
191
desc: desc,
192
+ scope: scope,
193
base: appendVecSet(base, vecSet),
194
}
195
}),
@@ -184,10 +202,12 @@ func (m *statefulMeter) GaugeVec(name string, labelKeys []string, opts ...Instru
202
keys := mustNormalizeVecLabelKeys(labelKeys)
203
base := appendLabelSets(m.sets, nil)
204
return &statefulGaugeVec{
187
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulGaugeInstrument {
205
+ scope: m.scope,
206
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulGaugeInstrument {
207
return &statefulGaugeInstrument{
208
backend: m.backend,
209
desc: desc,
210
+ scope: scope,
211
base: appendVecSet(base, vecSet),
212
}
213
}),
@@ -200,10 +220,12 @@ func (m *snapshotMeter) CounterVec(name string, labelKeys []string, opts ...Inst
220
keys := mustNormalizeVecLabelKeys(labelKeys)
221
base := appendLabelSets(m.sets, nil)
222
return &snapshotCounterVec{
203
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotCounterInstrument {
223
+ scope: m.scope,
224
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotCounterInstrument {
225
return &snapshotCounterInstrument{
226
backend: m.backend,
227
desc: desc,
228
+ scope: scope,
229
base: appendVecSet(base, vecSet),
230
}
231
}),
@@ -216,10 +238,12 @@ func (m *statefulMeter) CounterVec(name string, labelKeys []string, opts ...Inst
238
keys := mustNormalizeVecLabelKeys(labelKeys)
239
base := appendLabelSets(m.sets, nil)
240
return &statefulCounterVec{
219
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulCounterInstrument {
241
+ scope: m.scope,
242
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulCounterInstrument {
243
return &statefulCounterInstrument{
244
backend: m.backend,
245
desc: desc,
246
+ scope: scope,
247
base: appendVecSet(base, vecSet),
248
}
249
}),
@@ -232,10 +256,12 @@ func (m *snapshotMeter) HistogramVec(name string, labelKeys []string, opts ...In
256
keys := mustNormalizeVecLabelKeys(labelKeys)
257
base := appendLabelSets(m.sets, nil)
258
return &snapshotHistogramVec{
235
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotHistogramInstrument {
259
+ scope: m.scope,
260
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotHistogramInstrument {
261
return &snapshotHistogramInstrument{
262
backend: m.backend,
263
desc: desc,
264
+ scope: scope,
265
base: appendVecSet(base, vecSet),
266
}
267
}),
@@ -248,10 +274,12 @@ func (m *statefulMeter) HistogramVec(name string, labelKeys []string, opts ...In
274
keys := mustNormalizeVecLabelKeys(labelKeys)
275
base := appendLabelSets(m.sets, nil)
276
return &statefulHistogramVec{
251
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulHistogramInstrument {
277
+ scope: m.scope,
278
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulHistogramInstrument {
279
return &statefulHistogramInstrument{
280
backend: m.backend,
281
desc: desc,
282
+ scope: scope,
283
base: appendVecSet(base, vecSet),
284
}
285
}),
@@ -264,10 +292,12 @@ func (m *snapshotMeter) SummaryVec(name string, labelKeys []string, opts ...Inst
292
keys := mustNormalizeVecLabelKeys(labelKeys)
293
base := appendLabelSets(m.sets, nil)
294
return &snapshotSummaryVec{
267
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotSummaryInstrument {
295
+ scope: m.scope,
296
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotSummaryInstrument {
297
return &snapshotSummaryInstrument{
298
backend: m.backend,
299
desc: desc,
300
+ scope: scope,
301
base: appendVecSet(base, vecSet),
302
}
303
}),
@@ -280,10 +310,12 @@ func (m *statefulMeter) SummaryVec(name string, labelKeys []string, opts ...Inst
310
keys := mustNormalizeVecLabelKeys(labelKeys)
311
base := appendLabelSets(m.sets, nil)
312
return &statefulSummaryVec{
283
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulSummaryInstrument {
313
+ scope: m.scope,
314
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulSummaryInstrument {
315
return &statefulSummaryInstrument{
316
backend: m.backend,
317
desc: desc,
318
+ scope: scope,
319
base: appendVecSet(base, vecSet),
320
}
321
}),
@@ -296,10 +328,12 @@ func (m *snapshotMeter) StateSetVec(name string, labelKeys []string, opts ...Ins
328
keys := mustNormalizeVecLabelKeys(labelKeys)
329
base := appendLabelSets(m.sets, nil)
330
return &snapshotStateSetVec{
299
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotStateSetInstrument {
331
+ scope: m.scope,
332
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotStateSetInstrument {
333
return &snapshotStateSetInstrument{
334
backend: m.backend,
335
desc: desc,
336
+ scope: scope,
337
base: appendVecSet(base, vecSet),
338
}
339
}),
@@ -312,10 +346,12 @@ func (m *statefulMeter) StateSetVec(name string, labelKeys []string, opts ...Ins
346
keys := mustNormalizeVecLabelKeys(labelKeys)
347
base := appendLabelSets(m.sets, nil)
348
return &statefulStateSetVec{
315
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulStateSetInstrument {
349
+ scope: m.scope,
350
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulStateSetInstrument {
351
return &statefulStateSetInstrument{
352
backend: m.backend,
353
desc: desc,
354
+ scope: scope,
355
base: appendVecSet(base, vecSet),
356
}
357
}),
@@ -328,10 +364,12 @@ func (m *snapshotMeter) MeasureSetGaugeVec(name string, labelKeys []string, opts
364
keys := mustNormalizeVecLabelKeys(labelKeys)
365
base := appendLabelSets(m.sets, nil)
366
return &snapshotMeasureSetGaugeVec{
331
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotMeasureSetGaugeInstrument {
367
+ scope: m.scope,
368
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotMeasureSetGaugeInstrument {
369
return &snapshotMeasureSetGaugeInstrument{
370
backend: m.backend,
371
desc: desc,
372
+ scope: scope,
373
base: appendVecSet(base, vecSet),
374
}
375
}),
@@ -344,10 +382,12 @@ func (m *snapshotMeter) MeasureSetCounterVec(name string, labelKeys []string, op
382
keys := mustNormalizeVecLabelKeys(labelKeys)
383
base := appendLabelSets(m.sets, nil)
384
return &snapshotMeasureSetCounterVec{
347
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *snapshotMeasureSetCounterInstrument {
385
+ scope: m.scope,
386
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *snapshotMeasureSetCounterInstrument {
387
return &snapshotMeasureSetCounterInstrument{
388
backend: m.backend,
389
desc: desc,
390
+ scope: scope,
391
base: appendVecSet(base, vecSet),
392
}
393
}),
@@ -360,10 +400,12 @@ func (m *statefulMeter) MeasureSetGaugeVec(name string, labelKeys []string, opts
400
keys := mustNormalizeVecLabelKeys(labelKeys)
401
base := appendLabelSets(m.sets, nil)
402
return &statefulMeasureSetGaugeVec{
363
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulMeasureSetGaugeInstrument {
403
+ scope: m.scope,
404
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulMeasureSetGaugeInstrument {
405
return &statefulMeasureSetGaugeInstrument{
406
backend: m.backend,
407
desc: desc,
408
+ scope: scope,
409
base: appendVecSet(base, vecSet),
410
}
411
}),
@@ -376,19 +418,77 @@ func (m *statefulMeter) MeasureSetCounterVec(name string, labelKeys []string, op
418
keys := mustNormalizeVecLabelKeys(labelKeys)
419
base := appendLabelSets(m.sets, nil)
420
return &statefulMeasureSetCounterVec{
379
- cache: newVecCache(m.backend, base, keys, func(base []LabelSet, vecSet LabelSet) *statefulMeasureSetCounterInstrument {
421
+ scope: m.scope,
422
+ cache: newVecCache(m.backend, base, keys, func(scope HostScope, base []LabelSet, vecSet LabelSet) *statefulMeasureSetCounterInstrument {
423
return &statefulMeasureSetCounterInstrument{
424
backend: m.backend,
425
desc: desc,
426
+ scope: scope,
427
base: appendVecSet(base, vecSet),
428
}
429
}),
430
}
431
}
432
433
+func (v *snapshotGaugeVec) WithHostScope(scope HostScope) SnapshotGaugeVec {
434
+ return &snapshotGaugeVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
435
+}
436
+
437
+func (v *statefulGaugeVec) WithHostScope(scope HostScope) StatefulGaugeVec {
438
+ return &statefulGaugeVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
439
+}
440
+
441
+func (v *snapshotCounterVec) WithHostScope(scope HostScope) SnapshotCounterVec {
442
+ return &snapshotCounterVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
443
+}
444
+
445
+func (v *statefulCounterVec) WithHostScope(scope HostScope) StatefulCounterVec {
446
+ return &statefulCounterVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
447
+}
448
+
449
+func (v *snapshotHistogramVec) WithHostScope(scope HostScope) SnapshotHistogramVec {
450
+ return &snapshotHistogramVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
451
+}
452
+
453
+func (v *statefulHistogramVec) WithHostScope(scope HostScope) StatefulHistogramVec {
454
+ return &statefulHistogramVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
455
+}
456
+
457
+func (v *snapshotSummaryVec) WithHostScope(scope HostScope) SnapshotSummaryVec {
458
+ return &snapshotSummaryVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
459
+}
460
+
461
+func (v *statefulSummaryVec) WithHostScope(scope HostScope) StatefulSummaryVec {
462
+ return &statefulSummaryVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
463
+}
464
+
465
+func (v *snapshotStateSetVec) WithHostScope(scope HostScope) SnapshotStateSetVec {
466
+ return &snapshotStateSetVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
467
+}
468
+
469
+func (v *statefulStateSetVec) WithHostScope(scope HostScope) StatefulStateSetVec {
470
+ return &statefulStateSetVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
471
+}
472
+
473
+func (v *snapshotMeasureSetGaugeVec) WithHostScope(scope HostScope) SnapshotMeasureSetGaugeVec {
474
+ return &snapshotMeasureSetGaugeVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
475
+}
476
+
477
+func (v *snapshotMeasureSetCounterVec) WithHostScope(scope HostScope) SnapshotMeasureSetCounterVec {
478
+ return &snapshotMeasureSetCounterVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
479
+}
480
+
481
+func (v *statefulMeasureSetGaugeVec) WithHostScope(scope HostScope) StatefulMeasureSetGaugeVec {
482
+ return &statefulMeasureSetGaugeVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
483
+}
484
+
485
+func (v *statefulMeasureSetCounterVec) WithHostScope(scope HostScope) StatefulMeasureSetCounterVec {
486
+ return &statefulMeasureSetCounterVec{cache: v.cache, scope: mustNormalizeHostScope(scope)}
487
+}
488
+
489
// GetWithLabelValues returns a snapshot gauge handle for the provided vec label values.
490
func (v *snapshotGaugeVec) GetWithLabelValues(labelValues ...string) (SnapshotGauge, error) {
391
- inst, err := v.cache.get(labelValues...)
491
+ inst, err := v.cache.get(v.scope, labelValues...)
492
if err != nil {
493
return nil, err
494
}
@@ -406,7 +506,7 @@ func (v *snapshotGaugeVec) WithLabelValues(labelValues ...string) SnapshotGauge
506
507
// GetWithLabelValues returns a stateful gauge handle for the provided vec label values.
508
func (v *statefulGaugeVec) GetWithLabelValues(labelValues ...string) (StatefulGauge, error) {
409
- inst, err := v.cache.get(labelValues...)
509
+ inst, err := v.cache.get(v.scope, labelValues...)
510
if err != nil {
511
return nil, err
512
}
@@ -424,7 +524,7 @@ func (v *statefulGaugeVec) WithLabelValues(labelValues ...string) StatefulGauge
524
525
// GetWithLabelValues returns a snapshot counter handle for the provided vec label values.
526
func (v *snapshotCounterVec) GetWithLabelValues(labelValues ...string) (SnapshotCounter, error) {
427
- inst, err := v.cache.get(labelValues...)
527
+ inst, err := v.cache.get(v.scope, labelValues...)
528
if err != nil {
529
return nil, err
530
}
@@ -442,7 +542,7 @@ func (v *snapshotCounterVec) WithLabelValues(labelValues ...string) SnapshotCoun
542
543
// GetWithLabelValues returns a stateful counter handle for the provided vec label values.
544
func (v *statefulCounterVec) GetWithLabelValues(labelValues ...string) (StatefulCounter, error) {
445
- inst, err := v.cache.get(labelValues...)
545
+ inst, err := v.cache.get(v.scope, labelValues...)
546
if err != nil {
547
return nil, err
548
}
@@ -460,7 +560,7 @@ func (v *statefulCounterVec) WithLabelValues(labelValues ...string) StatefulCoun
560
561
// GetWithLabelValues returns a snapshot histogram handle for the provided vec label values.
562
func (v *snapshotHistogramVec) GetWithLabelValues(labelValues ...string) (SnapshotHistogram, error) {
463
- inst, err := v.cache.get(labelValues...)
563
+ inst, err := v.cache.get(v.scope, labelValues...)
564
if err != nil {
565
return nil, err
566
}
@@ -478,7 +578,7 @@ func (v *snapshotHistogramVec) WithLabelValues(labelValues ...string) SnapshotHi
578
579
// GetWithLabelValues returns a stateful histogram handle for the provided vec label values.
580
func (v *statefulHistogramVec) GetWithLabelValues(labelValues ...string) (StatefulHistogram, error) {
481
- inst, err := v.cache.get(labelValues...)
581
+ inst, err := v.cache.get(v.scope, labelValues...)
582
if err != nil {
583
return nil, err
584
}
@@ -496,7 +596,7 @@ func (v *statefulHistogramVec) WithLabelValues(labelValues ...string) StatefulHi
596
597
// GetWithLabelValues returns a snapshot summary handle for the provided vec label values.
598
func (v *snapshotSummaryVec) GetWithLabelValues(labelValues ...string) (SnapshotSummary, error) {
499
- inst, err := v.cache.get(labelValues...)
599
+ inst, err := v.cache.get(v.scope, labelValues...)
600
if err != nil {
601
return nil, err
602
}
@@ -514,7 +614,7 @@ func (v *snapshotSummaryVec) WithLabelValues(labelValues ...string) SnapshotSumm
614
615
// GetWithLabelValues returns a stateful summary handle for the provided vec label values.
616
func (v *statefulSummaryVec) GetWithLabelValues(labelValues ...string) (StatefulSummary, error) {
517
- inst, err := v.cache.get(labelValues...)
617
+ inst, err := v.cache.get(v.scope, labelValues...)
618
if err != nil {
619
return nil, err
620
}
@@ -532,7 +632,7 @@ func (v *statefulSummaryVec) WithLabelValues(labelValues ...string) StatefulSumm
632
633
// GetWithLabelValues returns a snapshot stateset handle for the provided vec label values.
634
func (v *snapshotStateSetVec) GetWithLabelValues(labelValues ...string) (StateSetInstrument, error) {
535
- inst, err := v.cache.get(labelValues...)
635
+ inst, err := v.cache.get(v.scope, labelValues...)
636
if err != nil {
637
return nil, err
638
}
@@ -550,7 +650,7 @@ func (v *snapshotStateSetVec) WithLabelValues(labelValues ...string) StateSetIns
650
651
// GetWithLabelValues returns a stateful stateset handle for the provided vec label values.
652
func (v *statefulStateSetVec) GetWithLabelValues(labelValues ...string) (StateSetInstrument, error) {
553
- inst, err := v.cache.get(labelValues...)
653
+ inst, err := v.cache.get(v.scope, labelValues...)
654
if err != nil {
655
return nil, err
656
}
@@ -568,7 +668,7 @@ func (v *statefulStateSetVec) WithLabelValues(labelValues ...string) StateSetIns
668
669
// GetWithLabelValues returns a snapshot MeasureSet gauge handle for the provided vec label values.
670
func (v *snapshotMeasureSetGaugeVec) GetWithLabelValues(labelValues ...string) (SnapshotMeasureSetGauge, error) {
571
- inst, err := v.cache.get(labelValues...)
671
+ inst, err := v.cache.get(v.scope, labelValues...)
672
if err != nil {
673
return nil, err
674
}
@@ -586,7 +686,7 @@ func (v *snapshotMeasureSetGaugeVec) WithLabelValues(labelValues ...string) Snap
686
687
// GetWithLabelValues returns a snapshot MeasureSet counter handle for the provided vec label values.
688
func (v *snapshotMeasureSetCounterVec) GetWithLabelValues(labelValues ...string) (SnapshotMeasureSetCounter, error) {
589
- inst, err := v.cache.get(labelValues...)
689
+ inst, err := v.cache.get(v.scope, labelValues...)
690
if err != nil {
691
return nil, err
692
}
@@ -604,7 +704,7 @@ func (v *snapshotMeasureSetCounterVec) WithLabelValues(labelValues ...string) Sn
704
705
// GetWithLabelValues returns a stateful MeasureSet gauge handle for the provided vec label values.
706
func (v *statefulMeasureSetGaugeVec) GetWithLabelValues(labelValues ...string) (StatefulMeasureSetGauge, error) {
607
- inst, err := v.cache.get(labelValues...)
707
+ inst, err := v.cache.get(v.scope, labelValues...)
708
if err != nil {
709
return nil, err
710
}
@@ -622,7 +722,7 @@ func (v *statefulMeasureSetGaugeVec) WithLabelValues(labelValues ...string) Stat
722
723
// GetWithLabelValues returns a stateful MeasureSet counter handle for the provided vec label values.
724
func (v *statefulMeasureSetCounterVec) GetWithLabelValues(labelValues ...string) (StatefulMeasureSetCounter, error) {
625
- inst, err := v.cache.get(labelValues...)
725
+ inst, err := v.cache.get(v.scope, labelValues...)
726
if err != nil {
727
return nil, err
728
}
@@ -697,3 +797,13 @@ func packVecLabelValues(values []string) string {
797
}
798
return b.String()
799
}
800
+
801
+func scopedVecCacheKey(scopeKey, labelsKey string) string {
802
+ if scopeKey == "" {
803
+ return labelsKey
804
+ }
805
+ if labelsKey == "" {
806
+ return "\xfe" + scopeKey
807
+ }
808
+ return "\xfe" + scopeKey + "\xff" + labelsKey
809
+}
src/go/pkg/netipc/service/cgroups/cgroups_unix_test.go
+2
-2
@@ -112,7 +112,7 @@ func (ts *unixTestServer) stop() {
112
113
func connectReadyUnix(t *testing.T, client *Client) {
114
t.Helper()
115
- for i := 0; i < 200; i++ {
115
+ for range 200 {
116
client.Refresh()
117
if client.Ready() {
118
return
@@ -162,7 +162,7 @@ func TestCacheRoundTripUnix(t *testing.T) {
162
defer cache.Close()
163
164
var updated bool
165
- for i := 0; i < 200; i++ {
165
+ for range 200 {
166
if cache.Refresh() {
167
updated = true
168
break
src/go/pkg/netipc/service/raw/cache_test.go
+2
-2
@@ -275,7 +275,7 @@ func TestCacheLargeDataset(t *testing.T) {
275
}
276
builder.SetHeader(1, 100)
277
278
- for i := uint32(0); i < N; i++ {
278
+ for i := range uint32(N) {
279
name := fmt.Sprintf("cgroup-%d", i)
280
path := fmt.Sprintf("/sys/fs/cgroup/test/%d", i)
281
enabled := uint32(1)
@@ -320,7 +320,7 @@ func TestCacheLargeDataset(t *testing.T) {
320
}
321
322
// Verify all lookups
323
- for i := uint32(0); i < N; i++ {
323
+ for i := range uint32(N) {
324
name := fmt.Sprintf("cgroup-%d", i)
325
item, found := cache.Lookup(i+1000, name)
326
if !found {
src/go/pkg/netipc/service/raw/client.go
+3
-9
@@ -153,20 +153,14 @@ func (c *Client) sessionMaxResponsePayloadBytes() uint32 {
153
}
154
155
func (c *Client) noteRequestCapacity(payloadLen uint32) {
156
- grown := nextPowerOf2U32(payloadLen)
157
- if grown > protocol.MaxPayloadCap {
158
- grown = protocol.MaxPayloadCap
159
- }
156
+ grown := min(nextPowerOf2U32(payloadLen), protocol.MaxPayloadCap)
157
if grown > c.config.MaxRequestPayloadBytes {
158
c.config.MaxRequestPayloadBytes = grown
159
}
160
}
161
162
func (c *Client) noteResponseCapacity(payloadLen uint32) {
166
- grown := nextPowerOf2U32(payloadLen)
167
- if grown > protocol.MaxPayloadCap {
168
- grown = protocol.MaxPayloadCap
169
- }
163
+ grown := min(nextPowerOf2U32(payloadLen), protocol.MaxPayloadCap)
164
if grown > c.config.MaxResponsePayloadBytes {
165
c.config.MaxResponsePayloadBytes = grown
166
}
@@ -477,7 +471,7 @@ func (c *Client) CallIncrementBatch(values []uint64) ([]uint64, error) {
471
472
// Extract each response item
473
out := make([]uint64, itemCount)
480
- for i := uint32(0); i < itemCount; i++ {
474
+ for i := range itemCount {
475
itemData, gerr := protocol.BatchItemGet(respPayload, itemCount, i)
476
if gerr != nil {
477
return gerr
src/go/pkg/netipc/service/raw/client_test.go
+5
-5
@@ -402,13 +402,13 @@ func TestConcurrentClients(t *testing.T) {
402
403
results := make(chan result, numClients)
404
405
- for i := 0; i < numClients; i++ {
405
+ for range numClients {
406
go func() {
407
r := result{}
408
client := NewSnapshotClient(testRunDir, svc, testClientConfig())
409
defer client.Close()
410
411
- for retry := 0; retry < 100; retry++ {
411
+ for range 100 {
412
client.Refresh()
413
if client.Ready() {
414
break
@@ -422,7 +422,7 @@ func TestConcurrentClients(t *testing.T) {
422
return
423
}
424
425
- for j := 0; j < requestsPerClient; j++ {
425
+ for range requestsPerClient {
426
view, err := client.CallSnapshot()
427
if err != nil || view.ItemCount != 3 {
428
r.failures++
@@ -442,7 +442,7 @@ func TestConcurrentClients(t *testing.T) {
442
443
totalSuccess := 0
444
totalFailure := 0
445
- for i := 0; i < numClients; i++ {
445
+ for range numClients {
446
r := <-results
447
totalSuccess += r.successes
448
totalFailure += r.failures
@@ -514,7 +514,7 @@ func TestStatusReporting(t *testing.T) {
514
}
515
516
// Make 3 successful calls
517
- for i := 0; i < 3; i++ {
517
+ for i := range 3 {
518
_, err := client.CallSnapshot()
519
if err != nil {
520
t.Fatalf("call %d failed: %v", i, err)
src/go/pkg/netipc/service/raw/more_unix_test.go
+1
-1
@@ -150,7 +150,7 @@ func startRawPosixSessionServerN(
150
go func() {
151
defer listener.Close()
152
153
- for i := 0; i < accepts; i++ {
153
+ for range accepts {
154
session, err := listener.Accept()
155
if err != nil {
156
srv.doneCh <- err
src/go/pkg/netipc/service/raw/ping_pong_test.go
+2
-2
@@ -52,7 +52,7 @@ func TestIncrementPingPong(t *testing.T) {
52
// 10 rounds: send 0 -> get 1 -> send 1 -> get 2 -> ... -> value == 10
53
var val uint64
54
responsesReceived := 0
55
- for i := 0; i < 10; i++ {
55
+ for i := range 10 {
56
got, err := client.CallIncrement(val)
57
if err != nil {
58
t.Fatalf("round %d: CallIncrement(%d) failed: %v", i, val, err)
@@ -108,7 +108,7 @@ func TestStringReversePingPong(t *testing.T) {
108
// 6 rounds: feed each response back as the next request
109
responsesReceived := 0
110
current := original
111
- for i := 0; i < 6; i++ {
111
+ for i := range 6 {
112
view, err := client.CallStringReverse(current)
113
if err != nil {
114
t.Fatalf("round %d: CallStringReverse(%q) failed: %v", i+1, current, err)
src/go/pkg/netipc/service/raw/stress_test.go
+21
-23
@@ -30,7 +30,7 @@ func largeHandler(n int) DispatchHandler {
30
}
31
builder.SetHeader(1, 42)
32
33
- for i := 0; i < n; i++ {
33
+ for i := range n {
34
name := fmt.Sprintf("container-%04d", i)
35
path := fmt.Sprintf("/sys/fs/cgroup/docker/%04d", i)
36
hash := simpleHash(name)
@@ -181,7 +181,7 @@ func TestStress1000Items(t *testing.T) {
181
if int(view.ItemCount) != N {
182
t.Fatalf("expected %d items, got %d", N, view.ItemCount)
183
}
184
- for i := 0; i < N; i++ {
184
+ for i := range N {
185
item, ierr := view.Item(uint32(i))
186
if ierr != nil {
187
t.Fatalf("item %d decode error: %v", i, ierr)
@@ -265,13 +265,13 @@ func TestStress50Clients(t *testing.T) {
265
266
start := time.Now()
267
268
- for i := 0; i < numClients; i++ {
268
+ for i := range numClients {
269
go func(id int) {
270
r := result{clientID: id}
271
client := NewSnapshotClient(testRunDir, svc, testClientConfig())
272
defer client.Close()
273
274
- for retry := 0; retry < 200; retry++ {
274
+ for range 200 {
275
client.Refresh()
276
if client.Ready() {
277
break
@@ -285,7 +285,7 @@ func TestStress50Clients(t *testing.T) {
285
return
286
}
287
288
- for j := 0; j < requestsPerClient; j++ {
288
+ for range requestsPerClient {
289
view, err := client.CallSnapshot()
290
if err != nil || view.ItemCount != 3 {
291
r.failures++
@@ -312,7 +312,7 @@ func TestStress50Clients(t *testing.T) {
312
313
totalSuccess := 0
314
totalFailure := 0
315
- for i := 0; i < numClients; i++ {
315
+ for range numClients {
316
r := <-results
317
totalSuccess += r.successes
318
totalFailure += r.failures
@@ -354,13 +354,13 @@ func TestStressConcurrentCacheClients(t *testing.T) {
354
355
start := time.Now()
356
357
- for i := 0; i < numClients; i++ {
357
+ for range numClients {
358
go func() {
359
r := result{}
360
cache := NewCache(testRunDir, svc, testClientConfig())
361
defer cache.Close()
362
363
- for j := 0; j < requestsPerClient; j++ {
363
+ for range requestsPerClient {
364
updated := cache.Refresh()
365
if updated || cache.Ready() {
366
status := cache.Status()
@@ -386,7 +386,7 @@ func TestStressConcurrentCacheClients(t *testing.T) {
386
387
totalSuccess := 0
388
totalFailure := 0
389
- for i := 0; i < numClients; i++ {
389
+ for range numClients {
390
r := <-results
391
totalSuccess += r.successes
392
totalFailure += r.failures
@@ -421,10 +421,10 @@ func TestStressRapidConnectDisconnect(t *testing.T) {
421
422
start := time.Now()
423
424
- for i := 0; i < cycles; i++ {
424
+ for range cycles {
425
client := NewSnapshotClient(testRunDir, svc, testClientConfig())
426
427
- for r := 0; r < 50; r++ {
427
+ for range 50 {
428
client.Refresh()
429
if client.Ready() {
430
break
@@ -483,10 +483,8 @@ func TestStressLongRunning60s(t *testing.T) {
483
var wg sync.WaitGroup
484
stop := make(chan struct{})
485
486
- for i := 0; i < numClients; i++ {
487
- wg.Add(1)
488
- go func() {
489
- defer wg.Done()
486
+ for range numClients {
487
+ wg.Go(func() {
488
cache := NewCache(testRunDir, svc, testClientConfig())
489
defer cache.Close()
490
@@ -511,7 +509,7 @@ func TestStressLongRunning60s(t *testing.T) {
509
510
time.Sleep(time.Millisecond)
511
}
514
- }()
512
+ })
513
}
514
515
time.Sleep(duration)
@@ -588,7 +586,7 @@ func TestStressMixedTransport(t *testing.T) {
586
client := NewSnapshotClient(testRunDir, svc, ccfg)
587
defer client.Close()
588
591
- for retry := 0; retry < 200; retry++ {
589
+ for range 200 {
590
client.Refresh()
591
if client.Ready() {
592
break
@@ -596,7 +594,7 @@ func TestStressMixedTransport(t *testing.T) {
594
time.Sleep(5 * time.Millisecond)
595
}
596
599
- for i := 0; i < 10; i++ {
597
+ for range 10 {
598
view, err := client.CallSnapshot()
599
if err == nil && view.ItemCount == 3 {
600
item0, ierr := view.Item(0)
@@ -620,7 +618,7 @@ func TestStressMixedTransport(t *testing.T) {
618
client := NewSnapshotClient(testRunDir, svc, ccfg)
619
defer client.Close()
620
623
- for retry := 0; retry < 200; retry++ {
621
+ for range 200 {
622
client.Refresh()
623
if client.Ready() {
624
break
@@ -628,7 +626,7 @@ func TestStressMixedTransport(t *testing.T) {
626
time.Sleep(5 * time.Millisecond)
627
}
628
631
- for i := 0; i < 10; i++ {
629
+ for range 10 {
630
view, err := client.CallSnapshot()
631
if err == nil && view.ItemCount == 3 {
632
r.success++
@@ -649,7 +647,7 @@ func TestStressMixedTransport(t *testing.T) {
647
client := NewSnapshotClient(testRunDir, svc, ccfg)
648
defer client.Close()
649
652
- for retry := 0; retry < 200; retry++ {
650
+ for range 200 {
651
client.Refresh()
652
if client.Ready() {
653
break
@@ -657,7 +655,7 @@ func TestStressMixedTransport(t *testing.T) {
655
time.Sleep(5 * time.Millisecond)
656
}
657
660
- for i := 0; i < 10; i++ {
658
+ for range 10 {
659
view, err := client.CallSnapshot()
660
if err == nil && view.ItemCount == 3 {
661
item0, ierr := view.Item(0)
@@ -673,7 +671,7 @@ func TestStressMixedTransport(t *testing.T) {
671
672
totalSuccess := 0
673
totalFailure := 0
676
- for i := 0; i < 3; i++ {
674
+ for range 3 {
675
r := <-results
676
t.Logf("client %d (%s): %d ok, %d fail", r.clientID, r.profile, r.success, r.failure)
677
totalSuccess += r.success
src/go/plugin/agent/jobmgr/job_factory.go
+4
@@ -18,6 +18,7 @@ import (
18
"github.com/netdata/netdata/go/plugins/plugin/framework/jobruntime"
19
"github.com/netdata/netdata/go/plugins/plugin/framework/metricsaudit"
20
"github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
21
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
22
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
23
)
24
@@ -46,6 +47,7 @@ type jobFactory struct {
47
auditDataDir string
48
49
runtimeService runtimecomp.Service
50
+ vnodeRegistry *vnoderegistry.Registry
51
52
secretResolver *secretresolver.Resolver
53
secretStoreSvc secretstore.Service
@@ -66,6 +68,7 @@ func newJobFactory(m *Manager) *jobFactory {
68
auditDataDir: m.auditDataDir,
69
70
runtimeService: m.runtimeService,
71
+ vnodeRegistry: m.vnodeRegistry,
72
secretResolver: m.secretResolver,
73
secretStoreSvc: m.secretsCtl.Service(),
74
ctx: m.baseContext(),
@@ -143,6 +146,7 @@ func (f *jobFactory) createV2(cfg confgroup.Config, creator collectorapi.Creator
146
Module: mod,
147
FunctionOnly: functionOnly,
148
RuntimeService: f.runtimeService,
149
+ VnodeRegistry: f.vnodeRegistry,
150
}
151
if vnode != nil {
152
jobCfg.Vnode = *vnode.Copy()
src/go/plugin/agent/jobmgr/manager.go
+8
@@ -27,6 +27,7 @@ import (
27
"github.com/netdata/netdata/go/plugins/plugin/framework/functions"
28
"github.com/netdata/netdata/go/plugins/plugin/framework/metricsaudit"
29
"github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
30
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
31
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
32
)
33
@@ -47,6 +48,7 @@ type Config struct {
48
AuditDataDir string
49
FunctionJSONWriter func(payload []byte, code int)
50
RuntimeService runtimecomp.Service
51
+ VnodeRegistry *vnoderegistry.Registry
52
}
53
54
const (
@@ -82,6 +84,10 @@ func New(cfg Config) *Manager {
84
storeCreators := backends.Creators()
85
secretStoreSvc = secretstore.NewService(storeCreators...)
86
}
87
+ vnodeRegistry := cfg.VnodeRegistry
88
+ if vnodeRegistry == nil {
89
+ vnodeRegistry = vnoderegistry.New()
90
+ }
91
92
mgr := &Manager{
93
Logger: logger.New().With(
@@ -116,6 +122,7 @@ func New(cfg Config) *Manager {
122
123
dyncfgResponder: api,
124
runtimeService: cfg.RuntimeService,
125
+ vnodeRegistry: vnodeRegistry,
126
secretResolver: secretresolver.New(),
127
}
128
mgr.funcCtl = funcctl.New(funcctl.Options{
@@ -233,6 +240,7 @@ type Manager struct {
240
// RuntimeService is an optional runtime/internal metrics registration seam.
241
// When set, V2 jobs may register per-job runtime components.
242
runtimeService runtimecomp.Service
243
+ vnodeRegistry *vnoderegistry.Registry
244
245
secretResolver *secretresolver.Resolver
246
}
src/go/plugin/framework/chartemit/apply.go
+9
-9
@@ -77,22 +77,22 @@ func emitHostSelection(api *netdataapi.API, env EmitEnv) error {
77
return nil
78
}
79
80
- guid := sanitizeWireID(env.HostScope.GUID)
80
+ guid := strings.TrimSpace(env.HostScope.GUID)
81
if guid == "" {
82
return fmt.Errorf("chartemit: emit env host scope guid is required")
83
}
84
+ if sanitizeWireID(guid) != guid {
85
+ return fmt.Errorf("chartemit: emit env host scope guid contains unsupported characters")
86
+ }
87
if env.HostScope.Define != nil {
85
- defineGUID := sanitizeWireID(env.HostScope.Define.GUID)
86
- if defineGUID == "" {
87
- return fmt.Errorf("chartemit: host define guid is required")
88
+ define, err := PrepareHostInfo(*env.HostScope.Define)
89
+ if err != nil {
90
+ return err
91
}
89
- if defineGUID != guid {
92
+ if define.GUID != guid {
93
return fmt.Errorf("chartemit: host define guid %q does not match host scope guid %q", env.HostScope.Define.GUID, env.HostScope.GUID)
94
}
92
- if strings.TrimSpace(env.HostScope.Define.Hostname) == "" {
93
- return fmt.Errorf("chartemit: host define hostname is required")
94
- }
95
- api.HOSTINFO(*env.HostScope.Define)
95
+ api.HOSTINFO(define)
96
}
97
api.HOST(guid)
98
return nil
src/go/plugin/framework/chartemit/apply_test.go
+53
@@ -538,6 +538,59 @@ func TestApplyPlanRejectsMismatchedHostDefine(t *testing.T) {
538
assert.Equal(t, "", buf.String())
539
}
540
541
+func TestPrepareHostInfoScenarios(t *testing.T) {
542
+ cases := map[string]struct {
543
+ info netdataapi.HostInfo
544
+ want netdataapi.HostInfo
545
+ wantErr string
546
+ }{
547
+ "rejects unsafe guid": {
548
+ info: netdataapi.HostInfo{
549
+ GUID: "node'\nguid",
550
+ Hostname: "node-host",
551
+ },
552
+ wantErr: "unsupported characters",
553
+ },
554
+ "rejects unsafe hostname": {
555
+ info: netdataapi.HostInfo{
556
+ GUID: "node-guid",
557
+ Hostname: "node'\nhost",
558
+ },
559
+ wantErr: "unsupported characters",
560
+ },
561
+ "normalizes labels": {
562
+ info: netdataapi.HostInfo{
563
+ GUID: "node-guid",
564
+ Hostname: "node-host",
565
+ Labels: map[string]string{
566
+ "region'\n": "eu'\n",
567
+ " ": "ignored",
568
+ },
569
+ },
570
+ want: netdataapi.HostInfo{
571
+ GUID: "node-guid",
572
+ Hostname: "node-host",
573
+ Labels: map[string]string{
574
+ "_hostname": "node-host",
575
+ "region": "eu ",
576
+ },
577
+ },
578
+ },
579
+ }
580
+
581
+ for name, tc := range cases {
582
+ t.Run(name, func(t *testing.T) {
583
+ got, err := PrepareHostInfo(tc.info)
584
+ if tc.wantErr != "" {
585
+ require.ErrorContains(t, err, tc.wantErr)
586
+ return
587
+ }
588
+ require.NoError(t, err)
589
+ assert.Equal(t, tc.want, got)
590
+ })
591
+ }
592
+}
593
+
594
func TestApplyPlanRemoveOnlyBatchStillSelectsHost(t *testing.T) {
595
var buf bytes.Buffer
596
api := netdataapi.New(&buf)
src/go/plugin/framework/chartemit/host.go
+33
-9
@@ -5,6 +5,7 @@ package chartemit
5
import (
6
"fmt"
7
"maps"
8
+ "sort"
9
"strings"
10
11
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
@@ -13,29 +14,29 @@ import (
14
// PrepareHostInfo normalizes host-definition payloads before HOST_DEFINE.
15
//
16
// Current semantics intentionally match v1 vnode emission:
16
-// - GUID/hostname must be present,
17
+// - GUID/hostname must be present and wire-safe,
18
// - "_hostname" is injected when absent,
18
-// - label values are sanitized for Netdata wire output.
19
+// - label keys and values are normalized for Netdata wire output.
20
func PrepareHostInfo(info netdataapi.HostInfo) (netdataapi.HostInfo, error) {
21
guid := strings.TrimSpace(info.GUID)
22
if guid == "" {
23
return netdataapi.HostInfo{}, fmt.Errorf("chartemit: host guid is required")
24
}
25
+ if sanitizeWireID(guid) != guid {
26
+ return netdataapi.HostInfo{}, fmt.Errorf("chartemit: host guid contains unsupported characters")
27
+ }
28
hostname := strings.TrimSpace(info.Hostname)
29
if hostname == "" {
30
return netdataapi.HostInfo{}, fmt.Errorf("chartemit: host hostname is required")
31
}
28
-
29
- labels := maps.Clone(info.Labels)
30
- if labels == nil {
31
- labels = make(map[string]string)
32
+ if sanitizeWireValue(hostname) != hostname {
33
+ return netdataapi.HostInfo{}, fmt.Errorf("chartemit: host hostname contains unsupported characters")
34
}
35
+
36
+ labels := normalizeHostInfoLabels(info.Labels)
37
if _, ok := labels["_hostname"]; !ok {
38
labels["_hostname"] = hostname
39
}
36
- for key, value := range labels {
37
- labels[key] = sanitizeWireValue(value)
38
- }
40
41
return netdataapi.HostInfo{
42
GUID: guid,
@@ -43,3 +44,26 @@ func PrepareHostInfo(info netdataapi.HostInfo) (netdataapi.HostInfo, error) {
44
Labels: labels,
45
}, nil
46
}
47
+
48
+func normalizeHostInfoLabels(in map[string]string) map[string]string {
49
+ labels := maps.Clone(in)
50
+ if len(labels) == 0 {
51
+ return make(map[string]string)
52
+ }
53
+
54
+ keys := make([]string, 0, len(labels))
55
+ for key := range labels {
56
+ keys = append(keys, key)
57
+ }
58
+ sort.Strings(keys)
59
+
60
+ out := make(map[string]string, len(labels))
61
+ for _, key := range keys {
62
+ sKey := sanitizeWireID(key)
63
+ if sKey == "" {
64
+ continue
65
+ }
66
+ out[sKey] = sanitizeWireValue(labels[key])
67
+ }
68
+ return out
69
+}
src/go/plugin/framework/chartengine/engine_test.go
+3
@@ -262,6 +262,9 @@ func TestEnginePreparePlanLifecycleScenarios(t *testing.T) {
262
e.ResetMaterialized()
263
264
require.ErrorIs(t, attempt.Commit(), ErrStalePlanAttempt)
265
+ nextAttempt, err := e.PreparePlan(store.Read())
266
+ require.NoError(t, err)
267
+ nextAttempt.Abort()
268
},
269
},
270
"repeated commit is rejected after successful commit": {
src/go/plugin/framework/chartengine/options.go
+17
@@ -19,6 +19,7 @@ type engineConfig struct {
19
selectorOverride policyOverride[metrixselector.Selector]
20
runtimeStore metrix.RuntimeStore
21
runtimeStoreSet bool
22
+ runtimeObserver func(PlanRuntimeSample)
23
log *logger.Logger
24
seriesSelection seriesSelectionMode
25
runtimePlanner bool
@@ -116,6 +117,22 @@ func WithRuntimeStore(store metrix.RuntimeStore) Option {
117
}
118
}
119
120
+// WithRuntimeSampleObserver configures a callback for per-build runtime samples.
121
+//
122
+// The callback fires for successful builds, build errors, and collect-status
123
+// skips. It does not fire for pre-build contract errors such as an outstanding
124
+// plan attempt.
125
+//
126
+// The callback is in addition to WithRuntimeStore. Pass WithRuntimeStore(nil)
127
+// when samples are aggregated elsewhere and the engine should not write its own
128
+// runtime metrics.
129
+func WithRuntimeSampleObserver(fn func(PlanRuntimeSample)) Option {
130
+ return func(cfg *engineConfig) error {
131
+ cfg.runtimeObserver = fn
132
+ return nil
133
+ }
134
+}
135
+
136
// WithLogger configures chartengine logger.
137
func WithLogger(l *logger.Logger) Option {
138
return func(cfg *engineConfig) error {
src/go/plugin/framework/chartengine/planner.go
+2
-3
@@ -123,9 +123,6 @@ func (e *Engine) preparePlan(reader metrix.Reader) (Plan, materializedState, uin
123
if reader == nil {
124
return Plan{}, materializedState{}, 0, 0, 0, false, fmt.Errorf("chartengine: nil metrics reader")
125
}
126
- sample := planRuntimeSample{startedAt: time.Now()}
127
- defer func() { e.observeBuildSample(sample) }()
128
-
126
out := Plan{
127
Actions: make([]EngineAction, 0),
128
InferredDimensions: make([]InferredDimension, 0),
@@ -137,6 +134,8 @@ func (e *Engine) preparePlan(reader metrix.Reader) (Plan, materializedState, uin
134
if e.state.outstanding != 0 {
135
return Plan{}, materializedState{}, 0, 0, 0, false, ErrOutstandingPlanAttempt
136
}
137
+ sample := PlanRuntimeSample{startedAt: time.Now()}
138
+ defer func() { e.observeBuildSample(sample) }()
139
// Failed attempt must not trigger lifecycle transitions.
140
if collectMeta.LastAttemptStatus != metrix.CollectStatusSuccess {
141
sample.skippedFailed = true
src/go/plugin/framework/chartengine/runtime_metrics.go
+203
-8
@@ -3,6 +3,7 @@
3
package chartengine
4
5
import (
6
+ "sync"
7
"time"
8
9
"github.com/netdata/netdata/go/plugins/pkg/metrix"
@@ -55,7 +56,11 @@ type runtimeMetrics struct {
56
lifecycleRemovedDimensionByExpiry metrix.StatefulCounter
57
}
58
58
-type planRuntimeSample struct {
59
+// PlanRuntimeSample is an opaque snapshot of one planner build used by
60
+// chartengine runtime observers and RuntimeAggregator. Its fields are
61
+// intentionally package-private so chartengine remains the owner of runtime
62
+// metric semantics while callers can pass samples between chartengine APIs.
63
+type PlanRuntimeSample struct {
64
startedAt time.Time
65
66
buildErr bool
@@ -192,7 +197,7 @@ func newRuntimeMetrics(store metrix.RuntimeStore) *runtimeMetrics {
197
),
198
routeCacheEntries: metrix.SeededGauge(meter,
199
"route_cache_entries",
195
- metrix.WithDescription("Current number of route cache entries"),
200
+ metrix.WithDescription("Route cache entries in the latest successful build rollup"),
201
metrix.WithChartFamily("ChartEngine/Route Cache"),
202
metrix.WithUnit("entries"),
203
),
@@ -244,13 +249,13 @@ func newRuntimeMetrics(store metrix.RuntimeStore) *runtimeMetrics {
249
250
planChartInstances: metrix.SeededGauge(meter,
251
"plan_chart_instances",
247
- metrix.WithDescription("Chart instances in last successful build plan"),
252
+ metrix.WithDescription("Chart instances in the latest successful build rollup"),
253
metrix.WithChartFamily("ChartEngine/Plan"),
254
metrix.WithUnit("charts"),
255
),
256
planInferredDimensions: metrix.SeededGauge(meter,
257
"plan_inferred_dimensions",
253
- metrix.WithDescription("Inferred dimensions in last successful build plan"),
258
+ metrix.WithDescription("Inferred dimensions in the latest successful build rollup"),
259
metrix.WithChartFamily("ChartEngine/Plan"),
260
metrix.WithUnit("dimensions"),
261
),
@@ -268,7 +273,7 @@ func newRuntimeMetrics(store metrix.RuntimeStore) *runtimeMetrics {
273
}
274
}
275
271
-func (m *runtimeMetrics) observeBuild(sample planRuntimeSample) {
276
+func (m *runtimeMetrics) observeBuild(sample PlanRuntimeSample) {
277
if m == nil {
278
return
279
}
@@ -384,18 +389,208 @@ func (e *Engine) RuntimeStore() metrix.RuntimeStore {
389
return e.state.runtimeStore
390
}
391
387
-func (e *Engine) observeBuildSample(sample planRuntimeSample) {
392
+func (e *Engine) observeBuildSample(sample PlanRuntimeSample) {
393
if e == nil {
394
return
395
}
396
+ if e.state.cfg.runtimeObserver != nil {
397
+ e.state.cfg.runtimeObserver(sample)
398
+ }
399
if e.state.runtimeStats == nil {
400
return
401
}
402
e.state.runtimeStats.observeBuild(sample)
403
}
404
397
-func actionKindCounts(actions []EngineAction) planRuntimeSample {
398
- out := planRuntimeSample{}
405
+// RuntimeAggregator records runtime samples from multiple engines and emits one
406
+// rolled-up chartengine runtime stream.
407
+type RuntimeAggregator struct {
408
+ mu sync.Mutex
409
+ metrics *runtimeMetrics
410
+ samples []PlanRuntimeSample
411
+}
412
+
413
+// NewRuntimeAggregator creates a runtime sample aggregator backed by store.
414
+func NewRuntimeAggregator(store metrix.RuntimeStore) *RuntimeAggregator {
415
+ return &RuntimeAggregator{metrics: newRuntimeMetrics(store)}
416
+}
417
+
418
+// Observe records one engine build sample.
419
+func (a *RuntimeAggregator) Observe(sample PlanRuntimeSample) {
420
+ if a == nil {
421
+ return
422
+ }
423
+ a.mu.Lock()
424
+ a.samples = append(a.samples, sample)
425
+ a.mu.Unlock()
426
+}
427
+
428
+// Reset drops accumulated samples without writing them.
429
+func (a *RuntimeAggregator) Reset() {
430
+ if a == nil {
431
+ return
432
+ }
433
+ a.mu.Lock()
434
+ a.samples = nil
435
+ a.mu.Unlock()
436
+}
437
+
438
+// Flush emits accumulated samples into the aggregate runtime store.
439
+func (a *RuntimeAggregator) Flush() {
440
+ if a == nil {
441
+ return
442
+ }
443
+ a.mu.Lock()
444
+ samples := a.samples
445
+ a.samples = nil
446
+ a.mu.Unlock()
447
+ if len(samples) == 0 || a.metrics == nil {
448
+ return
449
+ }
450
+ a.metrics.observeBuildRollup(samples)
451
+}
452
+
453
+func (m *runtimeMetrics) observeBuildRollup(samples []PlanRuntimeSample) {
454
+ if m == nil || len(samples) == 0 {
455
+ return
456
+ }
457
+
458
+ var buildSuccess, skippedFailed, buildErr int
459
+ var routeCacheHits, routeCacheMisses uint64
460
+ var routeCacheRetained, routeCachePruned, routeCacheFullDrops int
461
+ var seriesScanned, seriesMatched, seriesUnmatched, seriesAutogenMatched uint64
462
+ var seriesFilteredBySeq, seriesFilteredBySel uint64
463
+ var actionCreateChart, actionCreateDimension, actionUpdateChart, actionRemoveDimension, actionRemoveChart int
464
+ var lifecycleRemovedChartByCap, lifecycleRemovedChartByExpiry int
465
+ var lifecycleRemovedDimensionByCap, lifecycleRemovedDimensionByExpiry int
466
+ var routeCacheEntries, planChartInstances, planInferredDimensions int
467
+ var buildSeqBroken, buildSeqRecovered int
468
+ var buildSeqViolation, buildSeqObserved bool
469
+
470
+ for _, sample := range samples {
471
+ if sample.startedAt.IsZero() {
472
+ sample.startedAt = time.Now()
473
+ }
474
+ switch {
475
+ case sample.buildSuccess:
476
+ buildSuccess++
477
+ case sample.skippedFailed:
478
+ skippedFailed++
479
+ case sample.buildErr:
480
+ buildErr++
481
+ }
482
+
483
+ m.buildDurationSeconds.Observe(time.Since(sample.startedAt).Seconds())
484
+ observeDurationSeconds(m.buildPhasePrepareSec, sample.phasePrepareSeconds)
485
+ observeDurationSeconds(m.buildPhaseValidateSec, sample.phaseValidateSeconds)
486
+ observeDurationSeconds(m.buildPhaseScanSec, sample.phaseScanSeconds)
487
+ observeDurationSeconds(m.buildPhaseRetainSec, sample.phaseRetainSeconds)
488
+ observeDurationSeconds(m.buildPhaseCapsSec, sample.phaseLifecycleCapsSec)
489
+ observeDurationSeconds(m.buildPhaseMaterializeSec, sample.phaseMaterializeSeconds)
490
+ observeDurationSeconds(m.buildPhaseExpirySec, sample.phaseExpirySeconds)
491
+ observeDurationSeconds(m.buildPhaseSortSec, sample.phaseSortSeconds)
492
+
493
+ if sample.buildSeqObserved {
494
+ buildSeqObserved = true
495
+ if sample.buildSeqBroken {
496
+ buildSeqBroken++
497
+ }
498
+ if sample.buildSeqRecovered {
499
+ buildSeqRecovered++
500
+ }
501
+ if sample.buildSeqViolation {
502
+ buildSeqViolation = true
503
+ }
504
+ }
505
+
506
+ routeCacheHits += sample.routeCacheHits
507
+ routeCacheMisses += sample.routeCacheMisses
508
+ routeCacheRetained += sample.routeCacheRetained
509
+ routeCachePruned += sample.routeCachePruned
510
+ if sample.routeCacheFullDrop {
511
+ routeCacheFullDrops++
512
+ }
513
+ if sample.buildSuccess {
514
+ routeCacheEntries += sample.routeCacheEntries
515
+ planChartInstances += sample.planChartInstances
516
+ planInferredDimensions += sample.planInferredDimensions
517
+ }
518
+
519
+ seriesScanned += sample.seriesScanned
520
+ seriesMatched += sample.seriesMatched
521
+ seriesUnmatched += sample.seriesUnmatched
522
+ seriesAutogenMatched += sample.seriesAutogenMatched
523
+ seriesFilteredBySeq += sample.seriesFilteredBySeq
524
+ seriesFilteredBySel += sample.seriesFilteredBySel
525
+
526
+ actionCreateChart += sample.actionCreateChart
527
+ actionCreateDimension += sample.actionCreateDimension
528
+ actionUpdateChart += sample.actionUpdateChart
529
+ actionRemoveDimension += sample.actionRemoveDimension
530
+ actionRemoveChart += sample.actionRemoveChart
531
+
532
+ lifecycleRemovedChartByCap += sample.lifecycleRemovedChartByCap
533
+ lifecycleRemovedChartByExpiry += sample.lifecycleRemovedChartByExpiry
534
+ lifecycleRemovedDimensionByCap += sample.lifecycleRemovedDimensionByCap
535
+ lifecycleRemovedDimensionByExpiry += sample.lifecycleRemovedDimensionByExpiry
536
+ }
537
+
538
+ addIfPositive(m.buildSuccessTotal, float64(buildSuccess))
539
+ addIfPositive(m.buildSkippedFailedTotal, float64(skippedFailed))
540
+ addIfPositive(m.buildErrorTotal, float64(buildErr))
541
+ addIfPositive(m.buildSeqBrokenTotal, float64(buildSeqBroken))
542
+ addIfPositive(m.buildSeqRecoveredTotal, float64(buildSeqRecovered))
543
+ if buildSeqObserved {
544
+ if buildSeqViolation {
545
+ m.buildSeqViolation.Set(1)
546
+ } else {
547
+ m.buildSeqViolation.Set(0)
548
+ }
549
+ } else {
550
+ m.buildSeqViolation.Set(0)
551
+ }
552
+
553
+ addIfPositive(m.routeCacheHitsTotal, float64(routeCacheHits))
554
+ addIfPositive(m.routeCacheMissesTotal, float64(routeCacheMisses))
555
+ addIfPositive(m.routeCacheRetainedTotal, float64(routeCacheRetained))
556
+ addIfPositive(m.routeCachePrunedTotal, float64(routeCachePruned))
557
+ addIfPositive(m.routeCacheFullDropsTotal, float64(routeCacheFullDrops))
558
+ if buildSuccess > 0 {
559
+ m.routeCacheEntries.Set(float64(routeCacheEntries))
560
+ }
561
+
562
+ addIfPositive(m.seriesScannedTotal, float64(seriesScanned))
563
+ addIfPositive(m.seriesMatchedTotal, float64(seriesMatched))
564
+ addIfPositive(m.seriesUnmatchedTotal, float64(seriesUnmatched))
565
+ addIfPositive(m.seriesAutogenMatchedTotal, float64(seriesAutogenMatched))
566
+ addIfPositive(m.seriesFilteredBySeq, float64(seriesFilteredBySeq))
567
+ addIfPositive(m.seriesFilteredBySelector, float64(seriesFilteredBySel))
568
+
569
+ addIfPositive(m.actionCreateChart, float64(actionCreateChart))
570
+ addIfPositive(m.actionCreateDimension, float64(actionCreateDimension))
571
+ addIfPositive(m.actionUpdateChart, float64(actionUpdateChart))
572
+ addIfPositive(m.actionRemoveDimension, float64(actionRemoveDimension))
573
+ addIfPositive(m.actionRemoveChart, float64(actionRemoveChart))
574
+
575
+ addIfPositive(m.lifecycleRemovedChartByCap, float64(lifecycleRemovedChartByCap))
576
+ addIfPositive(m.lifecycleRemovedChartByExpiry, float64(lifecycleRemovedChartByExpiry))
577
+ addIfPositive(m.lifecycleRemovedDimensionByCap, float64(lifecycleRemovedDimensionByCap))
578
+ addIfPositive(m.lifecycleRemovedDimensionByExpiry, float64(lifecycleRemovedDimensionByExpiry))
579
+
580
+ if buildSuccess > 0 {
581
+ m.planChartInstances.Set(float64(planChartInstances))
582
+ m.planInferredDimensions.Set(float64(planInferredDimensions))
583
+ }
584
+}
585
+
586
+func addIfPositive(metric metrix.StatefulCounter, value float64) {
587
+ if value > 0 {
588
+ metric.Add(value)
589
+ }
590
+}
591
+
592
+func actionKindCounts(actions []EngineAction) PlanRuntimeSample {
593
+ out := PlanRuntimeSample{}
594
for _, action := range actions {
595
switch action.Kind() {
596
case ActionCreateChart:
src/go/plugin/framework/chartengine/runtime_metrics_test.go
+143
@@ -4,6 +4,7 @@ package chartengine
4
5
import (
6
"testing"
7
+ "time"
8
9
"github.com/stretchr/testify/assert"
10
"github.com/stretchr/testify/require"
@@ -244,6 +245,144 @@ func TestEngineRuntimeObservabilityScenarios(t *testing.T) {
245
assert.Equal(t, "seconds", durationSum.Meta.Units)
246
},
247
},
248
+ "runtime aggregator rolls up multiple engine samples": {
249
+ run: func(t *testing.T) {
250
+ store := metrix.NewRuntimeStore()
251
+ agg := NewRuntimeAggregator(store)
252
+ now := testNow()
253
+ agg.Observe(PlanRuntimeSample{
254
+ startedAt: now,
255
+ buildSuccess: true,
256
+ planRouteStats: planRouteStats{routeCacheHits: 2, seriesScanned: 3},
257
+ routeCacheEntries: 4,
258
+ planChartInstances: 5,
259
+ planInferredDimensions: 6,
260
+ buildSeqObserved: true,
261
+ buildSeqViolation: true,
262
+ })
263
+ agg.Observe(PlanRuntimeSample{
264
+ startedAt: now,
265
+ buildSuccess: true,
266
+ planRouteStats: planRouteStats{routeCacheHits: 7, seriesScanned: 11},
267
+ routeCacheEntries: 13,
268
+ planChartInstances: 17,
269
+ planInferredDimensions: 19,
270
+ buildSeqObserved: true,
271
+ })
272
+
273
+ agg.Flush()
274
+
275
+ r := store.Read(metrix.ReadRaw())
276
+ assertMetricValueAtLeast(t, r, "netdata.go.plugin.framework.chartengine.build_success_total", nil, 2)
277
+ assertMetricValueAtLeast(t, r, "netdata.go.plugin.framework.chartengine.route_cache_hits_total", nil, 9)
278
+ assertMetricValueAtLeast(t, r, "netdata.go.plugin.framework.chartengine.series_scanned_total", nil, 14)
279
+ routeCacheEntries, ok := r.Value("netdata.go.plugin.framework.chartengine.route_cache_entries", nil)
280
+ require.True(t, ok)
281
+ assert.Equal(t, float64(17), routeCacheEntries)
282
+ planChartInstances, ok := r.Value("netdata.go.plugin.framework.chartengine.plan_chart_instances", nil)
283
+ require.True(t, ok)
284
+ assert.Equal(t, float64(22), planChartInstances)
285
+ planInferredDimensions, ok := r.Value("netdata.go.plugin.framework.chartengine.plan_inferred_dimensions", nil)
286
+ require.True(t, ok)
287
+ assert.Equal(t, float64(25), planInferredDimensions)
288
+ seqViolation, ok := r.Value("netdata.go.plugin.framework.chartengine.build_seq_violation_active", nil)
289
+ require.True(t, ok)
290
+ assert.Equal(t, float64(1), seqViolation)
291
+ assertSummaryCountAtLeast(t, r, "netdata.go.plugin.framework.chartengine.build_duration_seconds", nil, 2)
292
+ },
293
+ },
294
+ "runtime sample observer fires without direct runtime store": {
295
+ run: func(t *testing.T) {
296
+ observed := 0
297
+ e, err := New(
298
+ WithRuntimeStore(nil),
299
+ WithRuntimeSampleObserver(func(PlanRuntimeSample) {
300
+ observed++
301
+ }),
302
+ )
303
+ require.NoError(t, err)
304
+ require.NoError(t, e.LoadYAML([]byte(runtimeObservabilityTemplateYAML()), 1))
305
+
306
+ store := metrix.NewCollectorStore()
307
+ cc := mustCycleController(t, store)
308
+ c := store.Write().SnapshotMeter("mysql").Counter("queries_total")
309
+ cc.BeginCycle()
310
+ c.ObserveTotal(10)
311
+ cc.CommitCycleSuccess()
312
+
313
+ _, err = buildPlan(e, store.Read(metrix.ReadFlatten()))
314
+ require.NoError(t, err)
315
+ assert.Equal(t, 1, observed)
316
+ assert.Nil(t, e.RuntimeStore())
317
+ },
318
+ },
319
+ "outstanding attempt does not fire runtime sample observer": {
320
+ run: func(t *testing.T) {
321
+ observed := 0
322
+ e, err := New(
323
+ WithRuntimeStore(nil),
324
+ WithRuntimeSampleObserver(func(PlanRuntimeSample) {
325
+ observed++
326
+ }),
327
+ )
328
+ require.NoError(t, err)
329
+ require.NoError(t, e.LoadYAML([]byte(runtimeObservabilityTemplateYAML()), 1))
330
+
331
+ store := metrix.NewCollectorStore()
332
+ cc := mustCycleController(t, store)
333
+ c := store.Write().SnapshotMeter("mysql").Counter("queries_total")
334
+ cc.BeginCycle()
335
+ c.ObserveTotal(10)
336
+ cc.CommitCycleSuccess()
337
+ reader := store.Read(metrix.ReadFlatten())
338
+
339
+ attempt, err := e.PreparePlan(reader)
340
+ require.NoError(t, err)
341
+ defer attempt.Abort()
342
+ assert.Equal(t, 1, observed)
343
+
344
+ _, err = e.PreparePlan(reader)
345
+ require.ErrorIs(t, err, ErrOutstandingPlanAttempt)
346
+ assert.Equal(t, 1, observed)
347
+ },
348
+ },
349
+ "runtime aggregator nil store and empty flush are no-ops": {
350
+ run: func(t *testing.T) {
351
+ agg := NewRuntimeAggregator(nil)
352
+ require.NotPanics(t, func() {
353
+ agg.Flush()
354
+ agg.Observe(PlanRuntimeSample{startedAt: testNow(), buildSuccess: true})
355
+ agg.Reset()
356
+ agg.Flush()
357
+ })
358
+ },
359
+ },
360
+ "runtime aggregator clears build sequence violation on skipped-only rollup": {
361
+ run: func(t *testing.T) {
362
+ store := metrix.NewRuntimeStore()
363
+ agg := NewRuntimeAggregator(store)
364
+
365
+ agg.Observe(PlanRuntimeSample{
366
+ startedAt: testNow(),
367
+ buildSuccess: true,
368
+ buildSeqObserved: true,
369
+ buildSeqViolation: true,
370
+ })
371
+ agg.Flush()
372
+ r := store.Read(metrix.ReadRaw())
373
+ seqViolation, ok := r.Value("netdata.go.plugin.framework.chartengine.build_seq_violation_active", nil)
374
+ require.True(t, ok)
375
+ assert.Equal(t, float64(1), seqViolation)
376
+
377
+ agg.Observe(PlanRuntimeSample{startedAt: testNow(), skippedFailed: true})
378
+ agg.Flush()
379
+ r = store.Read(metrix.ReadRaw())
380
+ seqViolation, ok = r.Value("netdata.go.plugin.framework.chartengine.build_seq_violation_active", nil)
381
+ require.True(t, ok)
382
+ assert.Equal(t, float64(0), seqViolation)
383
+ assertMetricValueAtLeast(t, r, "netdata.go.plugin.framework.chartengine.build_skipped_failed_collect_total", nil, 1)
384
+ },
385
+ },
386
}
387
388
for name, tc := range tests {
@@ -251,6 +390,10 @@ func TestEngineRuntimeObservabilityScenarios(t *testing.T) {
390
}
391
}
392
393
+func testNow() time.Time {
394
+ return time.Now().Add(-time.Second)
395
+}
396
+
397
func assertMetricValueAtLeast(t *testing.T, reader metrix.Reader, name string, labels metrix.Labels, min float64) {
398
t.Helper()
399
value, ok := reader.Value(name, labels)
src/go/plugin/framework/jobruntime/job_v2.go
+334
-62
@@ -5,6 +5,7 @@ package jobruntime
5
import (
6
"bytes"
7
"context"
8
+ "errors"
9
"fmt"
10
"io"
11
"log/slog"
@@ -22,6 +23,7 @@ import (
23
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
24
"github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
25
"github.com/netdata/netdata/go/plugins/plugin/framework/tickstate"
26
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
27
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
28
)
29
@@ -38,6 +40,7 @@ type JobV2Config struct {
40
AutoDetectEvery int
41
IsStock bool
42
Vnode vnodes.VirtualNode
43
+ VnodeRegistry *vnoderegistry.Registry
44
FunctionOnly bool
45
RuntimeService runtimecomp.Service
46
}
@@ -47,6 +50,10 @@ func NewJobV2(cfg JobV2Config) *JobV2 {
50
if cfg.UpdateEvery <= 0 {
51
cfg.UpdateEvery = 1
52
}
53
+ registry := cfg.VnodeRegistry
54
+ if registry == nil {
55
+ registry = vnoderegistry.New()
56
+ }
57
58
j := &JobV2{
59
pluginName: cfg.PluginName,
@@ -67,6 +74,7 @@ func NewJobV2(cfg JobV2Config) *JobV2 {
74
buf: &buf,
75
api: netdataapi.New(&buf),
76
vnode: cfg.Vnode,
77
+ vnodeRegistry: registry,
78
runtimeService: cfg.RuntimeService,
79
}
80
if j.out == nil {
@@ -105,9 +113,15 @@ type JobV2 struct {
113
initialized bool
114
panicked atomic.Bool
115
108
- store metrix.CollectorStore
109
- cycle metrix.CycleController
110
- engine *chartengine.Engine
116
+ store metrix.CollectorStore
117
+ cycle metrix.CycleController
118
+
119
+ scopeStates map[string]*jobV2ScopeState
120
+ chartTemplateYAML []byte
121
+ chartTemplateRevision uint64
122
+ engineOptions []chartengine.Option
123
+ runtimeStore metrix.RuntimeStore
124
+ runtimeAggregator *chartengine.RuntimeAggregator
125
126
prevRun time.Time
127
retries atomic.Int64
@@ -116,7 +130,7 @@ type JobV2 struct {
130
vnode vnodes.VirtualNode
131
updVnode chan *vnodes.VirtualNode
132
119
- hostState jobV2HostState
133
+ vnodeRegistry *vnoderegistry.Registry
134
135
ctxMu sync.RWMutex
136
runCtx context.Context
@@ -137,9 +151,24 @@ type JobV2 struct {
151
}
152
153
type jobV2PreparedEmission struct {
154
+ scopes []jobV2PreparedScopeEmission
155
+ scopeFailure bool
156
+}
157
+
158
+type jobV2PreparedScopeEmission struct {
159
+ scope *jobV2ScopeState
160
attempt chartengine.PlanAttempt
161
plan chartengine.Plan
162
decision jobV2EmissionDecision
163
+ output []byte
164
+ live bool
165
+}
166
+
167
+type jobV2ScopeState struct {
168
+ scopeKey string
169
+ scope metrix.HostScope
170
+ engine *chartengine.Engine
171
+ host jobV2HostState
172
}
173
174
func (j *JobV2) FullName() string { return j.fullName }
@@ -179,37 +208,44 @@ func (j *JobV2) UpdateVnode(vnode *vnodes.VirtualNode) {
208
}
209
func (j *JobV2) Cleanup() {
210
j.buf.Reset()
182
- snapshot := j.hostState.captureCleanupSnapshot(j.currentVnode())
211
+ snapshots := j.captureScopeCleanupSnapshots()
212
j.unregisterRuntimeComponent()
213
if j.module != nil {
214
j.module.Cleanup(context.Background())
215
}
216
if !collectorapi.ShouldObsoleteCharts() {
188
- return
189
- }
190
- if snapshot.staleVnodeSuppressed || len(snapshot.charts) == 0 {
217
+ j.releaseAllScopeRegistryOwners()
218
+ j.clearAllScopeStateAfterCleanup()
219
return
220
}
221
194
- env := chartemit.EmitEnv{
195
- TypeID: j.fullName,
196
- UpdateEvery: j.updateEvery,
197
- Plugin: j.pluginName,
198
- Module: j.moduleName,
199
- JobName: j.name,
200
- JobLabels: j.labels,
201
- }
202
- if snapshot.host.isVnode() {
203
- env.HostScope = &chartemit.HostScope{GUID: snapshot.host.guid}
204
- }
205
- if err := chartemit.ApplyPlan(j.api, buildJobV2CleanupPlan(snapshot.charts), env); err != nil {
206
- j.Warningf("cleanup apply plan failed: %v", err)
222
+ for _, snapshot := range snapshots {
223
+ if snapshot.staleVnodeSuppressed || len(snapshot.charts) == 0 {
224
+ continue
225
+ }
226
+
227
+ env := chartemit.EmitEnv{
228
+ TypeID: j.fullName,
229
+ UpdateEvery: j.updateEvery,
230
+ Plugin: j.pluginName,
231
+ Module: j.moduleName,
232
+ JobName: j.name,
233
+ JobLabels: j.labels,
234
+ }
235
+ if snapshot.host.isVnode() {
236
+ env.HostScope = &chartemit.HostScope{GUID: snapshot.host.guid}
237
+ }
238
+ j.buf.Reset()
239
+ if err := chartemit.ApplyPlan(j.api, buildJobV2CleanupPlan(snapshot.charts), env); err != nil {
240
+ j.Warningf("cleanup apply plan failed for host scope %q: %v", snapshot.scopeKey, err)
241
+ j.buf.Reset()
242
+ continue
243
+ }
244
+ _, _ = io.Copy(j.out, j.buf)
245
j.buf.Reset()
208
- return
246
}
210
- _, _ = io.Copy(j.out, j.buf)
211
- j.buf.Reset()
212
- j.hostState.clearAfterCleanup()
247
+ j.releaseAllScopeRegistryOwners()
248
+ j.clearAllScopeStateAfterCleanup()
249
}
250
251
func (j *JobV2) AutoDetection() (err error) {
@@ -343,25 +379,38 @@ func (j *JobV2) postCheck() error {
379
opts = append(opts, chartengine.WithEnginePolicy(v.EnginePolicy()))
380
}
381
346
- engine, err := chartengine.New(opts...)
347
- if err != nil {
348
- return err
349
- }
350
- if err := engine.LoadYAML([]byte(j.module.ChartTemplateYAML()), 1); err != nil {
382
+ templateYAML := []byte(j.module.ChartTemplateYAML())
383
+ if err := validateJobV2ChartTemplate(templateYAML, opts); err != nil {
384
return err
385
}
386
387
j.store = store
388
j.cycle = managed.CycleController()
356
- j.engine = engine
389
+ j.scopeStates = make(map[string]*jobV2ScopeState)
390
+ j.chartTemplateYAML = templateYAML
391
+ j.chartTemplateRevision = 1
392
+ j.engineOptions = opts
393
+ j.runtimeStore = metrix.NewRuntimeStore()
394
+ j.runtimeAggregator = chartengine.NewRuntimeAggregator(j.runtimeStore)
395
if err := j.registerRuntimeComponent(); err != nil {
396
j.Warningf("runtime metrics registration failed: %v", err)
397
}
398
return nil
399
}
400
401
+func validateJobV2ChartTemplate(templateYAML []byte, opts []chartengine.Option) error {
402
+ engineOpts := append([]chartengine.Option{}, opts...)
403
+ engineOpts = append(engineOpts, chartengine.WithRuntimeStore(nil))
404
+ engine, err := chartengine.New(engineOpts...)
405
+ if err != nil {
406
+ return err
407
+ }
408
+ return engine.LoadYAML(templateYAML, 1)
409
+}
410
+
411
func (j *JobV2) runOnce() {
412
defer j.ResetAllOnce()
413
+ defer j.flushRuntimeAggregator()
414
415
j.applyPendingVnodeUpdate()
416
@@ -384,6 +433,12 @@ func (j *JobV2) runOnce() {
433
j.buf.Reset()
434
}
435
436
+func (j *JobV2) flushRuntimeAggregator() {
437
+ if j != nil && j.runtimeAggregator != nil {
438
+ j.runtimeAggregator.Flush()
439
+ }
440
+}
441
+
442
func (j *JobV2) applyPendingVnodeUpdate() {
443
select {
444
case vnode := <-j.updVnode:
@@ -392,6 +447,7 @@ func (j *JobV2) applyPendingVnodeUpdate() {
447
}
448
if j.module != nil && j.module.VirtualNode() != nil {
449
// Match v1 ownership model: do not override module-owned vnode state.
450
+ j.Debugf("ignoring vnode update for module-owned vnode")
451
return
452
}
453
@@ -400,7 +456,11 @@ func (j *JobV2) applyPendingVnodeUpdate() {
456
j.vnodeMu.Lock()
457
j.vnode = *next
458
j.vnodeMu.Unlock()
403
- j.hostState.invalidateDefine()
459
+ // Registry owner release is intentionally tied to the next successful
460
+ // emission or cleanup, so obsolete emission can still select the old host.
461
+ if state := j.scopeStates[defaultHostScopeKey]; state != nil {
462
+ state.host.invalidateDefine()
463
+ }
464
default:
465
}
466
}
@@ -408,11 +468,14 @@ func (j *JobV2) applyPendingVnodeUpdate() {
468
func (j *JobV2) collectAndEmit(sinceLastRun int) (prepared jobV2PreparedEmission, ok bool) {
469
j.panicked.Store(false)
470
cycleOpen := false
411
- var attempt chartengine.PlanAttempt
412
- attemptPending := false
471
472
defer func() {
473
if r := recover(); r != nil {
474
+ j.rollbackPreparedEmission(prepared)
475
+ j.buf.Reset()
476
+ if j.runtimeAggregator != nil {
477
+ j.runtimeAggregator.Reset()
478
+ }
479
if cycleOpen {
480
// Recover path must close staged frame to keep subsequent cycles valid.
481
func() {
@@ -420,9 +483,7 @@ func (j *JobV2) collectAndEmit(sinceLastRun int) (prepared jobV2PreparedEmission
483
j.cycle.AbortCycle()
484
}()
485
}
423
- if attemptPending {
424
- attempt.Abort()
425
- }
486
+ j.abortPreparedEmission(prepared)
487
j.panicked.Store(true)
488
j.Errorf("PANIC: %v", r)
489
if logger.Level.Enabled(slog.LevelDebug) {
@@ -439,50 +500,175 @@ func (j *JobV2) collectAndEmit(sinceLastRun int) (prepared jobV2PreparedEmission
500
j.Warningf("collect failed: %v", err)
501
return jobV2PreparedEmission{}, false
502
}
442
- j.cycle.CommitCycleSuccess()
503
+ if err := j.cycle.CommitCycleSuccess(); err != nil {
504
+ cycleOpen = false
505
+ j.Warningf("commit cycle failed: %v", err)
506
+ return jobV2PreparedEmission{}, false
507
+ }
508
cycleOpen = false
509
445
- vnode := j.currentVnode()
446
- decision, err := j.hostState.prepareEmission(vnode)
510
+ liveSet := j.liveScopeSet()
511
+ workSet := j.scopeWorkSet(liveSet)
512
+ for _, scopeKey := range sortedScopeKeys(workSet) {
513
+ scope := workSet[scopeKey]
514
+ _, live := liveSet[scopeKey]
515
+ if !live {
516
+ if state := j.scopeStates[scopeKey]; state != nil {
517
+ scope = state.scope
518
+ }
519
+ }
520
+ scopePrepared, scopeOK := j.prepareScopeEmission(scope, live, sinceLastRun)
521
+ if !scopeOK {
522
+ prepared.scopeFailure = true
523
+ continue
524
+ }
525
+ prepared.scopes = append(prepared.scopes, scopePrepared)
526
+ }
527
+ j.Debugf("v2 scope count: %d", len(j.scopeStates))
528
+ if len(prepared.scopes) == 0 && prepared.scopeFailure {
529
+ return prepared, false
530
+ }
531
+ return prepared, true
532
+}
533
+
534
+func (j *JobV2) finishPreparedEmission(prepared jobV2PreparedEmission) error {
535
+ successes := 0
536
+ failures := 0
537
+ var finalErr error
538
+ for _, scope := range prepared.scopes {
539
+ if err := scope.attempt.Commit(); err != nil {
540
+ j.rollbackVnodeRegistryEmission(scope.decision)
541
+ failures++
542
+ finalErr = errors.Join(finalErr, err)
543
+ j.Warningf("finalize emission for host scope %q failed: %v", scope.scope.scopeKey, err)
544
+ continue
545
+ }
546
+ if len(scope.output) > 0 {
547
+ _, _ = j.out.Write(scope.output)
548
+ }
549
+ j.commitScopeEmission(scope)
550
+ successes++
551
+ }
552
+ if prepared.scopeFailure {
553
+ failures++
554
+ }
555
+ if successes == 0 && failures > 0 {
556
+ if finalErr != nil {
557
+ return finalErr
558
+ }
559
+ return fmt.Errorf("all host scope emissions failed")
560
+ }
561
+ return nil
562
+}
563
+
564
+func (j *JobV2) prepareScopeEmission(scope metrix.HostScope, live bool, sinceLastRun int) (prepared jobV2PreparedScopeEmission, ok bool) {
565
+ var attempt chartengine.PlanAttempt
566
+ var decision jobV2EmissionDecision
567
+ defer func() {
568
+ if r := recover(); r != nil {
569
+ j.rollbackVnodeRegistryEmission(decision)
570
+ attempt.Abort()
571
+ j.buf.Reset()
572
+ panic(r)
573
+ }
574
+ if !ok {
575
+ j.rollbackVnodeRegistryEmission(decision)
576
+ attempt.Abort()
577
+ j.buf.Reset()
578
+ }
579
+ }()
580
+
581
+ state, err := j.ensureScopeState(scope)
582
if err != nil {
448
- j.Warningf("prepare host state failed: %v", err)
449
- return jobV2PreparedEmission{}, false
583
+ j.Warningf("prepare host scope %q failed: %v", scope.ScopeKey, err)
584
+ return jobV2PreparedScopeEmission{}, false
585
}
451
- if decision.needEngineReload {
452
- j.engine.ResetMaterialized()
453
- j.hostState.onEngineReload(decision.targetHost)
586
+
587
+ if state.scopeKey == defaultHostScopeKey {
588
+ vnode := j.currentVnode()
589
+ decision, err = state.host.prepareEmission(vnode)
590
+ if err == nil && decision.needEngineReload {
591
+ state.engine.ResetMaterialized()
592
+ }
593
+ if err != nil {
594
+ j.Warningf("prepare default host scope failed: %v", err)
595
+ return jobV2PreparedScopeEmission{}, false
596
+ }
597
+ } else {
598
+ decision, err = state.host.prepareScopedEmission(state.scope)
599
+ if err == nil && decision.needEngineReload {
600
+ state.engine.ResetMaterialized()
601
+ }
602
+ if err != nil {
603
+ j.Warningf("prepare host scope %q failed: %v", state.scopeKey, err)
604
+ return jobV2PreparedScopeEmission{}, false
605
+ }
606
}
455
- attempt, err = j.engine.PreparePlan(j.store.Read(metrix.ReadRaw(), metrix.ReadFlatten()))
607
+
608
+ attempt, err = state.engine.PreparePlan(j.store.Read(metrix.ReadRaw(), metrix.ReadFlatten(), metrix.ReadHostScope(state.scopeKey)))
609
if err != nil {
457
- j.Warningf("build plan failed: %v", err)
458
- return jobV2PreparedEmission{}, false
610
+ j.Warningf("build plan for host scope %q failed: %v", state.scopeKey, err)
611
+ return jobV2PreparedScopeEmission{}, false
612
}
460
- attemptPending = true
613
plan := attempt.Plan()
614
+ if err := j.prepareScopeVnodeRegistryEmission(state, &decision, plan); err != nil {
615
+ j.Warningf("prepare vnode registry for host scope %q failed: %v", state.scopeKey, err)
616
+ return jobV2PreparedScopeEmission{}, false
617
+ }
618
619
+ j.buf.Reset()
620
env := j.emitEnv(sinceLastRun, decision)
621
if err := chartemit.ApplyPlan(j.api, plan, env); err != nil {
465
- attempt.Abort()
466
- attemptPending = false
467
- j.Warningf("apply plan failed: %v", err)
468
- return jobV2PreparedEmission{}, false
622
+ j.Warningf("apply plan for host scope %q failed: %v", state.scopeKey, err)
623
+ return jobV2PreparedScopeEmission{}, false
624
}
470
- return jobV2PreparedEmission{
625
+ output := append([]byte(nil), j.buf.Bytes()...)
626
+ j.buf.Reset()
627
+
628
+ prepared = jobV2PreparedScopeEmission{
629
+ scope: state,
630
attempt: attempt,
631
plan: plan,
632
decision: decision,
474
- }, true
633
+ output: output,
634
+ live: live,
635
+ }
636
+ return prepared, true
637
}
638
477
-func (j *JobV2) finishPreparedEmission(prepared jobV2PreparedEmission) error {
478
- if j.buf.Len() > 0 {
479
- _, _ = io.Copy(j.out, j.buf)
639
+func (j *JobV2) commitScopeEmission(prepared jobV2PreparedScopeEmission) {
640
+ if prepared.scope == nil {
641
+ return
642
}
481
- if err := prepared.attempt.Commit(); err != nil {
482
- return err
643
+ state := prepared.scope
644
+ if state.scopeKey == defaultHostScopeKey || prepared.decision.registryOwner != "" {
645
+ keep := make(map[vnoderegistry.Owner]struct{}, 1)
646
+ if prepared.decision.registryOwner != "" {
647
+ keep[prepared.decision.registryOwner] = struct{}{}
648
+ }
649
+ state.host.releaseSupersededRegistryOwnersExcept(
650
+ j.vnodeRegistry,
651
+ keep,
652
+ j.vnodeRegistryOwnerNamespacePrefix(state.scopeKey),
653
+ )
654
+ }
655
+ state.host.commitSuccessfulEmission(prepared.plan, prepared.decision)
656
+ if !prepared.live && len(state.host.cleanupCharts) == 0 {
657
+ state.host.releaseRegistryOwners(j.vnodeRegistry)
658
+ delete(j.scopeStates, state.scopeKey)
659
+ }
660
+}
661
+
662
+func (j *JobV2) rollbackPreparedEmission(prepared jobV2PreparedEmission) {
663
+ for _, scope := range prepared.scopes {
664
+ j.rollbackVnodeRegistryEmission(scope.decision)
665
+ }
666
+}
667
+
668
+func (j *JobV2) abortPreparedEmission(prepared jobV2PreparedEmission) {
669
+ for _, scope := range prepared.scopes {
670
+ scope.attempt.Abort()
671
}
484
- j.hostState.commitSuccessfulEmission(prepared.plan, prepared.decision)
485
- return nil
672
}
673
674
func (j *JobV2) emitEnv(sinceLastRun int, decision jobV2EmissionDecision) chartemit.EmitEnv {
@@ -510,6 +696,92 @@ func (j *JobV2) currentVnode() vnodes.VirtualNode {
696
return *j.vnode.Copy()
697
}
698
699
+func (j *JobV2) prepareScopeVnodeRegistryEmission(state *jobV2ScopeState, decision *jobV2EmissionDecision, plan chartengine.Plan) error {
700
+ if decision == nil || !decision.targetHost.isVnode() || len(plan.Actions) == 0 {
701
+ return nil
702
+ }
703
+ if state == nil {
704
+ return fmt.Errorf("nil host scope state")
705
+ }
706
+ if state.scopeKey == defaultHostScopeKey {
707
+ vnode := j.currentVnode()
708
+ return j.prepareVnodeRegistryEmission(decision, j.vnodeRegistryOwner(decision.targetHost), netdataapi.HostInfo{
709
+ GUID: vnode.GUID,
710
+ Hostname: vnode.Hostname,
711
+ Labels: vnode.Labels,
712
+ })
713
+ }
714
+ return j.prepareVnodeRegistryEmission(decision, j.vnodeRegistryScopedOwner(state.scopeKey, state.scope.GUID), metrixHostScopeInfo(state.scope))
715
+}
716
+
717
+func (j *JobV2) prepareVnodeRegistryEmission(decision *jobV2EmissionDecision, owner vnoderegistry.Owner, info netdataapi.HostInfo) error {
718
+ registryInfo := netdataapi.HostInfo{
719
+ GUID: info.GUID,
720
+ Hostname: info.Hostname,
721
+ Labels: maps.Clone(info.Labels),
722
+ }
723
+ result, err := j.vnodeRegistry.Register(owner, registryInfo)
724
+ if err != nil {
725
+ return err
726
+ }
727
+ if result.MetadataUpdated && result.UpdateFirstSeen {
728
+ j.Warningf(
729
+ "vnode registry metadata updated for guid %q: hostname %q replaced by %q",
730
+ result.Info.GUID,
731
+ result.Previous.Hostname,
732
+ result.Info.Hostname,
733
+ )
734
+ }
735
+
736
+ scope := &chartemit.HostScope{GUID: decision.targetHost.guid}
737
+ if result.NeedDefine {
738
+ scope.Define = &result.Info
739
+ }
740
+ decision.hostScope = scope
741
+ decision.defineInfo = result.Info
742
+ decision.registryOwner = owner
743
+ decision.registryRegistration = result
744
+ return nil
745
+}
746
+
747
+func (j *JobV2) rollbackVnodeRegistryEmission(decision jobV2EmissionDecision) {
748
+ if decision.registryOwner != "" {
749
+ j.vnodeRegistry.Rollback(decision.registryOwner, decision.registryRegistration)
750
+ }
751
+}
752
+
753
+const vnodeRegistryOwnerSeparator = "\xff"
754
+
755
+func (j *JobV2) vnodeRegistryOwnerPrefix() string {
756
+ // Keep the separator outside valid metrix scope keys and GUIDs so owner
757
+ // strings remain unambiguous without allocating a structured key.
758
+ return j.fullName + vnodeRegistryOwnerSeparator
759
+}
760
+
761
+func (j *JobV2) vnodeRegistryJobOwnerPrefix() string {
762
+ // Keep job-level vnode owners separate from future per-scope owners.
763
+ return j.vnodeRegistryOwnerPrefix() + "job" + vnodeRegistryOwnerSeparator
764
+}
765
+
766
+func (j *JobV2) vnodeRegistryScopedOwnerPrefix(scopeKey string) string {
767
+ return j.vnodeRegistryOwnerPrefix() + "scope" + vnodeRegistryOwnerSeparator + scopeKey + vnodeRegistryOwnerSeparator
768
+}
769
+
770
+func (j *JobV2) vnodeRegistryOwnerNamespacePrefix(scopeKey string) string {
771
+ if scopeKey == defaultHostScopeKey {
772
+ return j.vnodeRegistryJobOwnerPrefix()
773
+ }
774
+ return j.vnodeRegistryScopedOwnerPrefix(scopeKey)
775
+}
776
+
777
+func (j *JobV2) vnodeRegistryOwner(target jobV2HostRef) vnoderegistry.Owner {
778
+ return vnoderegistry.Owner(j.vnodeRegistryJobOwnerPrefix() + target.guid)
779
+}
780
+
781
+func (j *JobV2) vnodeRegistryScopedOwner(scopeKey, guid string) vnoderegistry.Owner {
782
+ return vnoderegistry.Owner(j.vnodeRegistryScopedOwnerPrefix(scopeKey) + guid)
783
+}
784
+
785
func (j *JobV2) penalty() int {
786
return penaltyFromRetries(int(j.retries.Load()), j.updateEvery)
787
}
src/go/plugin/framework/jobruntime/job_v2_cleanup.go
+46
-2
@@ -12,12 +12,13 @@ import (
12
)
13
14
type jobV2CleanupSnapshot struct {
15
+ scopeKey string
16
charts map[string]chartengine.ChartMeta
17
host jobV2HostRef
18
staleVnodeSuppressed bool
19
}
20
20
-func (s *jobV2HostState) captureCleanupSnapshot(vnode vnodes.VirtualNode) jobV2CleanupSnapshot {
21
+func (s *jobV2HostState) captureCleanupSnapshot(vnode vnodes.VirtualNode, allowStaleVnodeSuppression bool) jobV2CleanupSnapshot {
22
if s == nil {
23
return jobV2CleanupSnapshot{}
24
}
@@ -25,10 +26,53 @@ func (s *jobV2HostState) captureCleanupSnapshot(vnode vnodes.VirtualNode) jobV2C
26
return jobV2CleanupSnapshot{
27
charts: maps.Clone(s.cleanupCharts),
28
host: host,
28
- staleVnodeSuppressed: shouldSuppressCleanupForStaleVnode(host, vnode),
29
+ staleVnodeSuppressed: allowStaleVnodeSuppression && shouldSuppressCleanupForStaleVnode(host, vnode),
30
}
31
}
32
33
+func (j *JobV2) captureScopeCleanupSnapshots() []jobV2CleanupSnapshot {
34
+ if j == nil || len(j.scopeStates) == 0 {
35
+ return nil
36
+ }
37
+ vnode := j.currentVnode()
38
+ keys := sortedScopeStateKeys(j.scopeStates)
39
+
40
+ snapshots := make([]jobV2CleanupSnapshot, 0, len(keys))
41
+ for _, key := range keys {
42
+ state := j.scopeStates[key]
43
+ if state == nil {
44
+ continue
45
+ }
46
+ snapshot := state.host.captureCleanupSnapshot(vnode, key == defaultHostScopeKey)
47
+ snapshot.scopeKey = key
48
+ snapshots = append(snapshots, snapshot)
49
+ }
50
+ return snapshots
51
+}
52
+
53
+func (j *JobV2) releaseAllScopeRegistryOwners() {
54
+ if j == nil {
55
+ return
56
+ }
57
+ for _, state := range j.scopeStates {
58
+ if state != nil {
59
+ state.host.releaseRegistryOwners(j.vnodeRegistry)
60
+ }
61
+ }
62
+}
63
+
64
+func (j *JobV2) clearAllScopeStateAfterCleanup() {
65
+ if j == nil {
66
+ return
67
+ }
68
+ for _, state := range j.scopeStates {
69
+ if state != nil {
70
+ state.host.clearAfterCleanup()
71
+ }
72
+ }
73
+ clear(j.scopeStates)
74
+}
75
+
76
func (s *jobV2HostState) clearAfterCleanup() {
77
if s == nil {
78
return
src/go/plugin/framework/jobruntime/job_v2_host_state.go
+53
-37
@@ -5,10 +5,13 @@ package jobruntime
5
import (
6
"fmt"
7
"maps"
8
+ "strings"
9
10
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
11
"github.com/netdata/netdata/go/plugins/pkg/netdataapi"
12
"github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
13
"github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
14
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
15
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
16
)
17
@@ -37,11 +40,12 @@ func (r jobV2HostRef) isGlobal() bool { return r.kind == jobV2HostGlobal }
40
func (r jobV2HostRef) isVnode() bool { return r.kind == jobV2HostVnode }
41
42
type jobV2EmissionDecision struct {
40
- targetHost jobV2HostRef
41
- needEngineReload bool
42
- hostScope *chartemit.HostScope
43
- defineEmitted bool
44
- defineInfo netdataapi.HostInfo
43
+ targetHost jobV2HostRef
44
+ needEngineReload bool
45
+ hostScope *chartemit.HostScope
46
+ defineInfo netdataapi.HostInfo
47
+ registryOwner vnoderegistry.Owner
48
+ registryRegistration vnoderegistry.Registration
49
}
50
51
type jobV2HostState struct {
@@ -50,6 +54,9 @@ type jobV2HostState struct {
54
engineHost jobV2HostRef
55
cleanupOwner jobV2HostRef
56
cleanupCharts map[string]chartengine.ChartMeta
57
+ // registryOwners tracks successfully emitted vnode owners so cleanup can
58
+ // release them after obsolete-chart emission.
59
+ registryOwners map[vnoderegistry.Owner]string
60
}
61
62
func (s *jobV2HostState) invalidateDefine() {
@@ -70,51 +77,43 @@ func (s *jobV2HostState) prepareEmission(vnode vnodes.VirtualNode) (jobV2Emissio
77
return decision, nil
78
}
79
73
- info, needDefine, err := s.prepareDefine(vnode, target)
74
- if err != nil {
75
- return jobV2EmissionDecision{}, err
76
- }
80
scope := &chartemit.HostScope{GUID: target.guid}
78
- if needDefine {
79
- scope.Define = &info
80
- }
81
decision.hostScope = scope
82
- decision.defineEmitted = needDefine
83
- decision.defineInfo = info
82
return decision, nil
83
}
84
87
-func (s *jobV2HostState) prepareDefine(vnode vnodes.VirtualNode, target jobV2HostRef) (netdataapi.HostInfo, bool, error) {
88
- info, err := chartemit.PrepareHostInfo(netdataapi.HostInfo{
89
- GUID: vnode.GUID,
90
- Hostname: vnode.Hostname,
91
- Labels: vnode.Labels,
92
- })
93
- if err != nil {
94
- return netdataapi.HostInfo{}, false, err
95
- }
96
- if s != nil && s.definedHost == target && hostInfoEqual(s.definedInfo, info) {
97
- return netdataapi.HostInfo{}, false, nil
85
+func (s *jobV2HostState) prepareScopedEmission(scope metrix.HostScope) (jobV2EmissionDecision, error) {
86
+ target := jobV2HostRef{kind: jobV2HostVnode, guid: scope.GUID}
87
+ decision := jobV2EmissionDecision{
88
+ targetHost: target,
89
+ needEngineReload: s != nil && s.engineHost.isSet() && s.engineHost != target,
90
+ hostScope: &chartemit.HostScope{GUID: target.guid},
91
}
99
- return info, true, nil
92
+ return decision, nil
93
}
94
102
-func (s *jobV2HostState) onEngineReload(target jobV2HostRef) {
95
+func (s *jobV2HostState) commitSuccessfulEmission(plan chartengine.Plan, decision jobV2EmissionDecision) {
96
if s == nil {
97
return
98
}
106
- s.engineHost = target
107
-}
108
-
109
-func (s *jobV2HostState) commitSuccessfulEmission(plan chartengine.Plan, decision jobV2EmissionDecision) {
110
- if s == nil || len(plan.Actions) == 0 {
99
+ if len(plan.Actions) == 0 {
100
+ if decision.needEngineReload {
101
+ // Quiet host switches still need to finish after the scope attempt commits.
102
+ s.engineHost = decision.targetHost
103
+ }
104
return
105
}
106
s.engineHost = decision.targetHost
114
- if decision.defineEmitted {
107
+ if decision.hostScope != nil {
108
s.definedHost = decision.targetHost
109
s.definedInfo = decision.defineInfo
110
}
111
+ if decision.registryOwner != "" {
112
+ if s.registryOwners == nil {
113
+ s.registryOwners = make(map[vnoderegistry.Owner]string)
114
+ }
115
+ s.registryOwners[decision.registryOwner] = decision.targetHost.guid
116
+ }
117
if s.cleanupCharts == nil {
118
s.cleanupCharts = make(map[string]chartengine.ChartMeta)
119
}
@@ -153,10 +152,27 @@ func (s *jobV2HostState) commitSuccessfulEmission(plan chartengine.Plan, decisio
152
s.cleanupOwner = decision.targetHost
153
}
154
156
-func hostInfoEqual(left, right netdataapi.HostInfo) bool {
157
- return left.GUID == right.GUID &&
158
- left.Hostname == right.Hostname &&
159
- maps.Equal(left.Labels, right.Labels)
155
+func (s *jobV2HostState) releaseRegistryOwners(registry *vnoderegistry.Registry) {
156
+ if s == nil || registry == nil || len(s.registryOwners) == 0 {
157
+ return
158
+ }
159
+ for owner, guid := range s.registryOwners {
160
+ registry.Release(owner, guid)
161
+ delete(s.registryOwners, owner)
162
+ }
163
+}
164
+
165
+func (s *jobV2HostState) releaseSupersededRegistryOwnersExcept(registry *vnoderegistry.Registry, current map[vnoderegistry.Owner]struct{}, ownerPrefix string) {
166
+ if s == nil || registry == nil || len(s.registryOwners) == 0 {
167
+ return
168
+ }
169
+ for owner, guid := range s.registryOwners {
170
+ if _, ok := current[owner]; ok || !strings.HasPrefix(string(owner), ownerPrefix) {
171
+ continue
172
+ }
173
+ registry.Release(owner, guid)
174
+ delete(s.registryOwners, owner)
175
+ }
176
}
177
178
func (r jobV2HostRef) String() string {
src/go/plugin/framework/jobruntime/job_v2_runtime.go
+1
-4
@@ -13,10 +13,7 @@ func (j *JobV2) registerRuntimeComponent() error {
13
if j == nil || j.runtimeService == nil || j.runtimeComponentRegistered {
14
return nil
15
}
16
- if j.engine == nil {
17
- return fmt.Errorf("nil engine")
18
- }
19
- store := j.engine.RuntimeStore()
16
+ store := j.runtimeStore
17
if store == nil {
18
return fmt.Errorf("nil runtime store")
19
}
src/go/plugin/framework/jobruntime/job_v2_scope.go
new
+133
@@ -0,0 +1,133 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package jobruntime
4
+
5
+import (
6
+ "fmt"
7
+ "maps"
8
+ "sort"
9
+
10
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
11
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
12
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
13
+)
14
+
15
+const defaultHostScopeKey = ""
16
+
17
+func (j *JobV2) ensureScopeState(scope metrix.HostScope) (*jobV2ScopeState, error) {
18
+ if j == nil {
19
+ return nil, fmt.Errorf("nil job")
20
+ }
21
+ scopeKey := scope.ScopeKey
22
+ if scope.IsDefault() {
23
+ scope = metrix.HostScope{}
24
+ scopeKey = defaultHostScopeKey
25
+ }
26
+ if j.scopeStates == nil {
27
+ j.scopeStates = make(map[string]*jobV2ScopeState)
28
+ }
29
+ if state := j.scopeStates[scopeKey]; state != nil {
30
+ state.scope = scope
31
+ return state, nil
32
+ }
33
+
34
+ engine, err := j.newScopeEngine()
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ state := &jobV2ScopeState{
39
+ scopeKey: scopeKey,
40
+ scope: scope,
41
+ engine: engine,
42
+ }
43
+ j.scopeStates[scopeKey] = state
44
+ return state, nil
45
+}
46
+
47
+func (j *JobV2) newScopeEngine() (*chartengine.Engine, error) {
48
+ opts := append([]chartengine.Option{}, j.engineOptions...)
49
+ opts = append(opts, chartengine.WithRuntimeStore(nil))
50
+ if j.runtimeAggregator != nil {
51
+ opts = append(opts, chartengine.WithRuntimeSampleObserver(j.runtimeAggregator.Observe))
52
+ }
53
+ engine, err := chartengine.New(opts...)
54
+ if err != nil {
55
+ return nil, err
56
+ }
57
+ if err := engine.LoadYAML(j.chartTemplateYAML, j.chartTemplateRevision); err != nil {
58
+ return nil, err
59
+ }
60
+ return engine, nil
61
+}
62
+
63
+func (j *JobV2) liveScopeSet() map[string]metrix.HostScope {
64
+ scopes := make(map[string]metrix.HostScope)
65
+ reader := j.store.Read(metrix.ReadRaw(), metrix.ReadFlatten())
66
+ for _, scope := range reader.HostScopes() {
67
+ if j.scopeHasVisibleSeries(scope.ScopeKey) {
68
+ scopes[scope.ScopeKey] = scope
69
+ }
70
+ }
71
+ return scopes
72
+}
73
+
74
+func (j *JobV2) scopeWorkSet(liveScopes map[string]metrix.HostScope) map[string]metrix.HostScope {
75
+ scopes := make(map[string]metrix.HostScope, len(liveScopes)+len(j.scopeStates))
76
+ maps.Copy(scopes, liveScopes)
77
+ // Retain previously emitted scopes until their engine emits lifecycle
78
+ // removals. This includes default scope when unscoped series disappear.
79
+ for key, state := range j.scopeStates {
80
+ if _, ok := scopes[key]; ok {
81
+ continue
82
+ }
83
+ scopes[key] = state.scope
84
+ }
85
+ return scopes
86
+}
87
+
88
+func (j *JobV2) scopeHasVisibleSeries(scopeKey string) bool {
89
+ reader := j.store.Read(metrix.ReadFlatten(), metrix.ReadHostScope(scopeKey))
90
+ found := false
91
+ reader.ForEachSeries(func(string, metrix.LabelView, metrix.SampleValue) {
92
+ found = true
93
+ })
94
+ return found
95
+}
96
+
97
+func sortedScopeKeys(scopes map[string]metrix.HostScope) []string {
98
+ keys := make([]string, 0, len(scopes))
99
+ for key := range scopes {
100
+ keys = append(keys, key)
101
+ }
102
+ sortHostScopeKeys(keys)
103
+ return keys
104
+}
105
+
106
+func sortedScopeStateKeys(scopes map[string]*jobV2ScopeState) []string {
107
+ keys := make([]string, 0, len(scopes))
108
+ for key := range scopes {
109
+ keys = append(keys, key)
110
+ }
111
+ sortHostScopeKeys(keys)
112
+ return keys
113
+}
114
+
115
+func sortHostScopeKeys(keys []string) {
116
+ sort.Slice(keys, func(i, j int) bool {
117
+ if keys[i] == defaultHostScopeKey {
118
+ return true
119
+ }
120
+ if keys[j] == defaultHostScopeKey {
121
+ return false
122
+ }
123
+ return keys[i] < keys[j]
124
+ })
125
+}
126
+
127
+func metrixHostScopeInfo(scope metrix.HostScope) netdataapi.HostInfo {
128
+ return netdataapi.HostInfo{
129
+ GUID: scope.GUID,
130
+ Hostname: scope.Hostname,
131
+ Labels: scope.Labels,
132
+ }
133
+}
src/go/plugin/framework/jobruntime/job_v2_test.go
+912
-44
@@ -7,6 +7,7 @@ import (
7
"context"
8
"errors"
9
"fmt"
10
+ "strings"
11
"testing"
12
"time"
13
@@ -15,6 +16,7 @@ import (
16
"github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
17
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
18
"github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
19
+ "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
20
"github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
21
"github.com/stretchr/testify/assert"
22
"github.com/stretchr/testify/require"
@@ -43,6 +45,12 @@ type mockRuntimeComponentService struct {
45
unregistered []string
46
}
47
48
+type writeFunc func([]byte) (int, error)
49
+
50
+func (f writeFunc) Write(p []byte) (int, error) {
51
+ return f(p)
52
+}
53
+
54
func (m *mockRuntimeComponentService) RegisterComponent(cfg runtimecomp.ComponentConfig) error {
55
if m.registerErr != nil {
56
return m.registerErr
@@ -128,6 +136,39 @@ func newTestJobV2WithVnode(mod collectorapi.CollectorV2, out *bytes.Buffer, vnod
136
})
137
}
138
139
+func newRegistryTestJobV2(t *testing.T, fullName string, registry *vnoderegistry.Registry, out *bytes.Buffer, vnode vnodes.VirtualNode) *JobV2 {
140
+ t.Helper()
141
+ store := metrix.NewCollectorStore()
142
+ mod := &mockModuleV2{
143
+ store: store,
144
+ template: chartTemplateV2(),
145
+ collectFunc: func(context.Context) error {
146
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
147
+ return nil
148
+ },
149
+ }
150
+ job := NewJobV2(JobV2Config{
151
+ PluginName: pluginName,
152
+ Name: fullName,
153
+ ModuleName: modName,
154
+ FullName: fullName,
155
+ Module: mod,
156
+ Out: out,
157
+ UpdateEvery: 1,
158
+ Vnode: vnode,
159
+ VnodeRegistry: registry,
160
+ })
161
+ require.NoError(t, job.AutoDetection())
162
+ return job
163
+}
164
+
165
+func requireDefaultScopeState(t *testing.T, job *JobV2) *jobV2ScopeState {
166
+ t.Helper()
167
+ state := job.scopeStates[defaultHostScopeKey]
168
+ require.NotNil(t, state)
169
+ return state
170
+}
171
+
172
func chartTemplateV2() string {
173
return `
174
version: v1
@@ -146,6 +187,26 @@ groups:
187
`
188
}
189
190
+func chartTemplateV2ExpireAfterOne() string {
191
+ return `
192
+version: v1
193
+groups:
194
+ - family: Workers
195
+ metrics:
196
+ - apache.workers_busy
197
+ charts:
198
+ - id: workers_busy
199
+ title: Workers Busy
200
+ context: workers_busy
201
+ units: workers
202
+ lifecycle:
203
+ expire_after_cycles: 1
204
+ dimensions:
205
+ - selector: apache.workers_busy
206
+ name: busy
207
+`
208
+}
209
+
210
func chartTemplateV2Dynamic() string {
211
return `
212
version: v1
@@ -183,8 +244,10 @@ func TestJobV2Scenarios(t *testing.T) {
244
require.NoError(t, job.AutoDetection())
245
require.NotNil(t, job.store)
246
require.NotNil(t, job.cycle)
186
- require.NotNil(t, job.engine)
187
- attempt, err := job.engine.PreparePlan(job.store.Read(metrix.ReadFlatten()))
247
+ state, err := job.ensureScopeState(metrix.HostScope{})
248
+ require.NoError(t, err)
249
+ require.NotNil(t, state.engine)
250
+ attempt, err := state.engine.PreparePlan(job.store.Read(metrix.ReadFlatten()))
251
require.NoError(t, err)
252
defer attempt.Abort()
253
err = attempt.Commit()
@@ -370,7 +433,7 @@ END`, chartengine.Priority, chartengine.Priority))
433
assert.NotContains(t, cfg.JobLabels, "source")
434
assert.NotContains(t, cfg.JobLabels, "collector_module")
435
require.NotNil(t, cfg.Store)
373
- assert.Equal(t, job.engine.RuntimeStore(), cfg.Store)
436
+ assert.Equal(t, job.runtimeStore, cfg.Store)
437
},
438
},
439
"module context carries runtime component service when available": {
@@ -922,7 +985,7 @@ BEGIN 'module_job.workers_busy'`,
985
require.NoError(t, err)
986
987
job.runOnce()
925
- require.Equal(t, initialInfo, job.hostState.definedInfo)
988
+ require.Equal(t, initialInfo, requireDefaultScopeState(t, job).host.definedInfo)
989
990
out.Reset()
991
tc.mutate(modVnode)
@@ -941,40 +1004,797 @@ BEGIN 'module_job.workers_busy'`)
1004
1005
expectedInfo, err := chartemit.PrepareHostInfo(tc.wantInfo)
1006
require.NoError(t, err)
944
- assert.Equal(t, expectedInfo, job.hostState.definedInfo)
1007
+ assert.Equal(t, expectedInfo, requireDefaultScopeState(t, job).host.definedInfo)
1008
})
1009
}
1010
}
1011
949
-func TestJobV2EmptyPlanDoesNotMarkVnodeDefined(t *testing.T) {
950
- store := metrix.NewCollectorStore()
951
- emitValue := false
952
- mod := &mockModuleV2{
953
- store: store,
954
- template: chartTemplateV2(),
955
- collectFunc: func(context.Context) error {
956
- if emitValue {
957
- store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
958
- }
959
- return nil
1012
+func TestJobV2VnodeRegistryScenarios(t *testing.T) {
1013
+ cases := map[string]struct {
1014
+ run func(t *testing.T)
1015
+ }{
1016
+ "shared registry suppresses duplicate and updates changed metadata": {
1017
+ run: func(t *testing.T) {
1018
+ registry := vnoderegistry.New()
1019
+ jobAOut := &bytes.Buffer{}
1020
+ jobBOut := &bytes.Buffer{}
1021
+
1022
+ jobA := newRegistryTestJobV2(t, "module_job_a", registry, jobAOut, vnodes.VirtualNode{
1023
+ Hostname: "node-host-a",
1024
+ GUID: "node-guid",
1025
+ Labels: map[string]string{
1026
+ "region": "eu",
1027
+ },
1028
+ })
1029
+ jobB := newRegistryTestJobV2(t, "module_job_b", registry, jobBOut, vnodes.VirtualNode{
1030
+ Hostname: "node-host-b",
1031
+ GUID: "node-guid",
1032
+ Labels: map[string]string{
1033
+ "region": "us",
1034
+ },
1035
+ })
1036
+
1037
+ jobA.runOnce()
1038
+ assert.Contains(t, jobAOut.String(), `HOST_DEFINE 'node-guid' 'node-host-a'`)
1039
+ assert.Contains(t, jobAOut.String(), `HOST 'node-guid'`)
1040
+
1041
+ jobB.runOnce()
1042
+ assert.Contains(t, jobBOut.String(), `HOST_DEFINE 'node-guid' 'node-host-b'`)
1043
+ assert.Contains(t, jobBOut.String(), `HOST 'node-guid'`)
1044
+
1045
+ info, ok := registry.Lookup("node-guid")
1046
+ require.True(t, ok)
1047
+ assert.Equal(t, "node-host-b", info.Hostname)
1048
+
1049
+ jobAOut.Reset()
1050
+ jobA.runOnce()
1051
+ assert.Contains(t, jobAOut.String(), `HOST_DEFINE 'node-guid' 'node-host-a'`)
1052
+ info, ok = registry.Lookup("node-guid")
1053
+ require.True(t, ok)
1054
+ assert.Equal(t, "node-host-a", info.Hostname)
1055
+
1056
+ assert.Equal(t, []vnoderegistry.Owner{
1057
+ vnoderegistry.Owner("module_job_a\xffjob\xffnode-guid"),
1058
+ vnoderegistry.Owner("module_job_b\xffjob\xffnode-guid"),
1059
+ }, registry.Owners("node-guid"))
1060
+
1061
+ jobA.Cleanup()
1062
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job_b\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1063
+ jobB.Cleanup()
1064
+ assert.Equal(t, 0, registry.Len())
1065
+ },
1066
+ },
1067
+ "rollback on apply failure": {
1068
+ run: func(t *testing.T) {
1069
+ registry := vnoderegistry.New()
1070
+ _, err := registry.Register("other", netdataapi.HostInfo{
1071
+ GUID: "node-guid",
1072
+ Hostname: "node-host-a",
1073
+ })
1074
+ require.NoError(t, err)
1075
+
1076
+ store := metrix.NewCollectorStore()
1077
+ mod := &mockModuleV2{
1078
+ store: store,
1079
+ template: chartTemplateV2(),
1080
+ collectFunc: func(context.Context) error {
1081
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1082
+ return nil
1083
+ },
1084
+ }
1085
+
1086
+ var out bytes.Buffer
1087
+ job := NewJobV2(JobV2Config{
1088
+ PluginName: pluginName,
1089
+ Name: jobName,
1090
+ ModuleName: modName,
1091
+ FullName: strings.Repeat("a", 1200),
1092
+ Module: mod,
1093
+ Out: &out,
1094
+ UpdateEvery: 1,
1095
+ VnodeRegistry: registry,
1096
+ Vnode: vnodes.VirtualNode{
1097
+ Hostname: "node-host-b",
1098
+ GUID: "node-guid",
1099
+ },
1100
+ })
1101
+ require.NoError(t, job.AutoDetection())
1102
+
1103
+ job.runOnce()
1104
+
1105
+ assert.Empty(t, out.String())
1106
+ info, ok := registry.Lookup("node-guid")
1107
+ require.True(t, ok)
1108
+ assert.Equal(t, "node-host-a", info.Hostname)
1109
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("other")}, registry.Owners("node-guid"))
1110
+ },
1111
+ },
1112
+ "rollback on commit failure emits nothing and next cycle recovers": {
1113
+ run: func(t *testing.T) {
1114
+ registry := vnoderegistry.New()
1115
+ store := metrix.NewCollectorStore()
1116
+ current := 1.0
1117
+ mod := &mockModuleV2{
1118
+ store: store,
1119
+ template: chartTemplateV2(),
1120
+ collectFunc: func(context.Context) error {
1121
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1122
+ return nil
1123
+ },
1124
+ }
1125
+
1126
+ var out bytes.Buffer
1127
+ job := NewJobV2(JobV2Config{
1128
+ PluginName: pluginName,
1129
+ Name: jobName,
1130
+ ModuleName: modName,
1131
+ FullName: modName + "_" + jobName,
1132
+ Module: mod,
1133
+ Out: &out,
1134
+ UpdateEvery: 1,
1135
+ VnodeRegistry: registry,
1136
+ Vnode: vnodes.VirtualNode{
1137
+ Hostname: "node-host",
1138
+ GUID: "node-guid",
1139
+ },
1140
+ })
1141
+ require.NoError(t, job.AutoDetection())
1142
+
1143
+ prepared, ok := job.collectAndEmit(0)
1144
+ require.True(t, ok)
1145
+ require.Len(t, prepared.scopes, 1)
1146
+ require.NotEmpty(t, prepared.scopes[0].output)
1147
+ assert.NotEmpty(t, registry.Owners("node-guid"))
1148
+
1149
+ requireDefaultScopeState(t, job).engine.ResetMaterialized()
1150
+ require.ErrorIs(t, job.finishPreparedEmission(prepared), chartengine.ErrStalePlanAttempt)
1151
+ assert.Empty(t, out.String())
1152
+ assert.Empty(t, registry.Owners("node-guid"))
1153
+
1154
+ current = 2
1155
+ job.runOnce()
1156
+ assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1157
+ assert.Contains(t, out.String(), "SET 'busy' = 2")
1158
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1159
+ },
1160
+ },
1161
+ "guid change releases superseded owner": {
1162
+ run: func(t *testing.T) {
1163
+ registry := vnoderegistry.New()
1164
+ store := metrix.NewCollectorStore()
1165
+ current := 1.0
1166
+ mod := &mockModuleV2{
1167
+ store: store,
1168
+ template: chartTemplateV2(),
1169
+ collectFunc: func(context.Context) error {
1170
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1171
+ return nil
1172
+ },
1173
+ }
1174
+
1175
+ var out bytes.Buffer
1176
+ job := NewJobV2(JobV2Config{
1177
+ PluginName: pluginName,
1178
+ Name: jobName,
1179
+ ModuleName: modName,
1180
+ FullName: modName + "_" + jobName,
1181
+ Module: mod,
1182
+ Out: &out,
1183
+ UpdateEvery: 1,
1184
+ VnodeRegistry: registry,
1185
+ Vnode: vnodes.VirtualNode{
1186
+ Hostname: "node-host-a",
1187
+ GUID: "node-guid-a",
1188
+ },
1189
+ })
1190
+ require.NoError(t, job.AutoDetection())
1191
+
1192
+ job.runOnce()
1193
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid-a")}, registry.Owners("node-guid-a"))
1194
+
1195
+ out.Reset()
1196
+ current = 2
1197
+ job.UpdateVnode(&vnodes.VirtualNode{
1198
+ Hostname: "node-host-b",
1199
+ GUID: "node-guid-b",
1200
+ })
1201
+ job.runOnce()
1202
+
1203
+ assert.Empty(t, registry.Owners("node-guid-a"))
1204
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid-b")}, registry.Owners("node-guid-b"))
1205
+ },
1206
+ },
1207
+ "vnode to global switch releases superseded owner": {
1208
+ run: func(t *testing.T) {
1209
+ registry := vnoderegistry.New()
1210
+ store := metrix.NewCollectorStore()
1211
+ current := 1.0
1212
+ mod := &mockModuleV2{
1213
+ store: store,
1214
+ template: chartTemplateV2(),
1215
+ collectFunc: func(context.Context) error {
1216
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1217
+ return nil
1218
+ },
1219
+ }
1220
+
1221
+ var out bytes.Buffer
1222
+ job := NewJobV2(JobV2Config{
1223
+ PluginName: pluginName,
1224
+ Name: jobName,
1225
+ ModuleName: modName,
1226
+ FullName: modName + "_" + jobName,
1227
+ Module: mod,
1228
+ Out: &out,
1229
+ UpdateEvery: 1,
1230
+ VnodeRegistry: registry,
1231
+ Vnode: vnodes.VirtualNode{
1232
+ Hostname: "node-host",
1233
+ GUID: "node-guid",
1234
+ },
1235
+ })
1236
+ require.NoError(t, job.AutoDetection())
1237
+
1238
+ job.runOnce()
1239
+ require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1240
+
1241
+ out.Reset()
1242
+ current = 2
1243
+ job.UpdateVnode(&vnodes.VirtualNode{})
1244
+ job.runOnce()
1245
+
1246
+ assert.Empty(t, registry.Owners("node-guid"))
1247
+ assert.Contains(t, out.String(), `HOST ''`)
1248
+ assert.Contains(t, out.String(), "SET 'busy' = 2")
1249
+ },
1250
+ },
1251
+ "cleanup emits obsoletes before releasing owner": {
1252
+ run: func(t *testing.T) {
1253
+ registry := vnoderegistry.New()
1254
+ var out bytes.Buffer
1255
+ job := newRegistryTestJobV2(t, "module_job", registry, &out, vnodes.VirtualNode{
1256
+ Hostname: "node-host",
1257
+ GUID: "node-guid",
1258
+ })
1259
+
1260
+ job.runOnce()
1261
+ require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1262
+
1263
+ ownerPresentDuringWrite := false
1264
+ job.out = writeFunc(func(p []byte) (int, error) {
1265
+ ownerPresentDuringWrite = assert.Contains(t, registry.Owners("node-guid"), vnoderegistry.Owner("module_job\xffjob\xffnode-guid"))
1266
+ return len(p), nil
1267
+ })
1268
+
1269
+ job.Cleanup()
1270
+
1271
+ assert.True(t, ownerPresentDuringWrite)
1272
+ assert.Empty(t, registry.Owners("node-guid"))
1273
+ },
1274
+ },
1275
+ "cleanup with obsolete disabled releases owners and clears state": {
1276
+ run: func(t *testing.T) {
1277
+ registry := vnoderegistry.New()
1278
+ var out bytes.Buffer
1279
+ job := newRegistryTestJobV2(t, "module_job", registry, &out, vnodes.VirtualNode{
1280
+ Hostname: "node-host",
1281
+ GUID: "node-guid",
1282
+ })
1283
+
1284
+ job.runOnce()
1285
+ require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1286
+ require.NotEmpty(t, job.scopeStates)
1287
+
1288
+ out.Reset()
1289
+ collectorapi.ObsoleteCharts(false)
1290
+ defer collectorapi.ObsoleteCharts(true)
1291
+ job.Cleanup()
1292
+
1293
+ assert.Empty(t, out.String())
1294
+ assert.Empty(t, registry.Owners("node-guid"))
1295
+ assert.Empty(t, job.scopeStates)
1296
+ },
1297
+ },
1298
+ "bad hostname aborts cycle without owner leak": {
1299
+ run: func(t *testing.T) {
1300
+ registry := vnoderegistry.New()
1301
+ store := metrix.NewCollectorStore()
1302
+ mod := &mockModuleV2{
1303
+ store: store,
1304
+ template: chartTemplateV2(),
1305
+ collectFunc: func(context.Context) error {
1306
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1307
+ return nil
1308
+ },
1309
+ }
1310
+
1311
+ var out bytes.Buffer
1312
+ job := NewJobV2(JobV2Config{
1313
+ PluginName: pluginName,
1314
+ Name: jobName,
1315
+ ModuleName: modName,
1316
+ FullName: modName + "_" + jobName,
1317
+ Module: mod,
1318
+ Out: &out,
1319
+ UpdateEvery: 1,
1320
+ VnodeRegistry: registry,
1321
+ Vnode: vnodes.VirtualNode{
1322
+ Hostname: "bad\nhost",
1323
+ GUID: "node-guid",
1324
+ },
1325
+ })
1326
+ require.NoError(t, job.AutoDetection())
1327
+
1328
+ job.runOnce()
1329
+
1330
+ assert.Empty(t, out.String())
1331
+ assert.Empty(t, registry.Owners("node-guid"))
1332
+ },
1333
+ },
1334
+ "empty plan does not reserve registry": {
1335
+ run: func(t *testing.T) {
1336
+ store := metrix.NewCollectorStore()
1337
+ registry := vnoderegistry.New()
1338
+ emitValue := false
1339
+ mod := &mockModuleV2{
1340
+ store: store,
1341
+ template: chartTemplateV2(),
1342
+ collectFunc: func(context.Context) error {
1343
+ if emitValue {
1344
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1345
+ }
1346
+ return nil
1347
+ },
1348
+ }
1349
+
1350
+ var out bytes.Buffer
1351
+ job := NewJobV2(JobV2Config{
1352
+ PluginName: pluginName,
1353
+ Name: jobName,
1354
+ ModuleName: modName,
1355
+ FullName: modName + "_" + jobName,
1356
+ Module: mod,
1357
+ Out: &out,
1358
+ UpdateEvery: 1,
1359
+ VnodeRegistry: registry,
1360
+ Vnode: vnodes.VirtualNode{
1361
+ Hostname: "node-host",
1362
+ GUID: "node-guid",
1363
+ },
1364
+ })
1365
+ require.NoError(t, job.AutoDetection())
1366
+
1367
+ job.runOnce()
1368
+ assert.Equal(t, "", out.String())
1369
+ assert.Equal(t, 0, registry.Len())
1370
+
1371
+ emitValue = true
1372
+ job.runOnce()
1373
+ assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1374
+ assert.Equal(t, 1, registry.Len())
1375
+ },
1376
+ },
1377
+ "empty plan does not mark vnode defined": {
1378
+ run: func(t *testing.T) {
1379
+ store := metrix.NewCollectorStore()
1380
+ emitValue := false
1381
+ mod := &mockModuleV2{
1382
+ store: store,
1383
+ template: chartTemplateV2(),
1384
+ collectFunc: func(context.Context) error {
1385
+ if emitValue {
1386
+ store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1387
+ }
1388
+ return nil
1389
+ },
1390
+ }
1391
+
1392
+ var out bytes.Buffer
1393
+ job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1394
+ Hostname: "node-host",
1395
+ GUID: "node-guid",
1396
+ })
1397
+ require.NoError(t, job.AutoDetection())
1398
+
1399
+ job.runOnce()
1400
+ assert.Equal(t, "", out.String())
1401
+ assert.Nil(t, job.scopeStates[defaultHostScopeKey])
1402
+
1403
+ emitValue = true
1404
+ job.runOnce()
1405
+ assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1406
+ assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid"}, requireDefaultScopeState(t, job).host.definedHost)
1407
+ },
1408
},
1409
}
1410
963
- var out bytes.Buffer
964
- job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
965
- Hostname: "node-host",
966
- GUID: "node-guid",
967
- })
968
- require.NoError(t, job.AutoDetection())
1411
+ for name, tc := range cases {
1412
+ t.Run(name, tc.run)
1413
+ }
1414
+}
1415
970
- job.runOnce()
971
- assert.Equal(t, "", out.String())
972
- assert.False(t, job.hostState.definedHost.isSet())
1416
+func TestJobV2HostScopeScenarios(t *testing.T) {
1417
+ scopeA := metrix.HostScope{ScopeKey: "scope-a", GUID: "guid-a", Hostname: "host-a", Labels: map[string]string{"workload": "a"}}
1418
+ scopeB := metrix.HostScope{ScopeKey: "scope-b", GUID: "guid-b", Hostname: "host-b", Labels: map[string]string{"workload": "b"}}
1419
974
- emitValue = true
975
- job.runOnce()
976
- assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
977
- assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid"}, job.hostState.definedHost)
1420
+ cases := map[string]struct {
1421
+ run func(t *testing.T)
1422
+ }{
1423
+ "mixed default and explicit scopes emit deterministic host batches": {
1424
+ run: func(t *testing.T) {
1425
+ store := metrix.NewCollectorStore()
1426
+ mod := &mockModuleV2{
1427
+ store: store,
1428
+ template: chartTemplateV2(),
1429
+ collectFunc: func(context.Context) error {
1430
+ meter := store.Write().SnapshotMeter("apache")
1431
+ meter.Gauge("workers_busy").Observe(1)
1432
+ meter.WithHostScope(scopeB).Gauge("workers_busy").Observe(2)
1433
+ meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(3)
1434
+ return nil
1435
+ },
1436
+ }
1437
+
1438
+ var out bytes.Buffer
1439
+ registry := vnoderegistry.New()
1440
+ job := NewJobV2(JobV2Config{
1441
+ PluginName: pluginName,
1442
+ Name: jobName,
1443
+ ModuleName: modName,
1444
+ FullName: modName + "_" + jobName,
1445
+ Module: mod,
1446
+ Out: &out,
1447
+ UpdateEvery: 1,
1448
+ VnodeRegistry: registry,
1449
+ })
1450
+ require.NoError(t, job.AutoDetection())
1451
+
1452
+ job.runOnce()
1453
+
1454
+ wire := out.String()
1455
+ assert.Contains(t, wire, `HOST ''
1456
+
1457
+CHART 'module_job.workers_busy'`)
1458
+ assert.Contains(t, wire, `HOST_DEFINE 'guid-a' 'host-a'`)
1459
+ assert.Contains(t, wire, `HOST 'guid-a'
1460
+
1461
+CHART 'module_job.workers_busy'`)
1462
+ assert.Contains(t, wire, `HOST_DEFINE 'guid-b' 'host-b'`)
1463
+ assert.Contains(t, wire, `HOST 'guid-b'
1464
+
1465
+CHART 'module_job.workers_busy'`)
1466
+ assertContainsInOrder(t, wire, "HOST ''", "HOST_DEFINE 'guid-a'", "HOST_DEFINE 'guid-b'")
1467
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffscope\xffscope-a\xffguid-a")}, registry.Owners("guid-a"))
1468
+ assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffscope\xffscope-b\xffguid-b")}, registry.Owners("guid-b"))
1469
+ },
1470
+ },
1471
+ "bad explicit scope does not block default scope": {
1472
+ run: func(t *testing.T) {
1473
+ store := metrix.NewCollectorStore()
1474
+ badScope := metrix.HostScope{ScopeKey: "bad", GUID: "bad-guid", Hostname: "bad\nhost"}
1475
+ mod := &mockModuleV2{
1476
+ store: store,
1477
+ template: chartTemplateV2(),
1478
+ collectFunc: func(context.Context) error {
1479
+ meter := store.Write().SnapshotMeter("apache")
1480
+ meter.Gauge("workers_busy").Observe(1)
1481
+ meter.WithHostScope(badScope).Gauge("workers_busy").Observe(2)
1482
+ return nil
1483
+ },
1484
+ }
1485
+
1486
+ var out bytes.Buffer
1487
+ registry := vnoderegistry.New()
1488
+ job := NewJobV2(JobV2Config{
1489
+ PluginName: pluginName,
1490
+ Name: jobName,
1491
+ ModuleName: modName,
1492
+ FullName: modName + "_" + jobName,
1493
+ Module: mod,
1494
+ Out: &out,
1495
+ UpdateEvery: 1,
1496
+ VnodeRegistry: registry,
1497
+ })
1498
+ require.NoError(t, job.AutoDetection())
1499
+
1500
+ job.runOnce()
1501
+
1502
+ wire := out.String()
1503
+ assert.Contains(t, wire, `HOST ''
1504
+
1505
+CHART 'module_job.workers_busy'`)
1506
+ assert.Contains(t, wire, "SET 'busy' = 1")
1507
+ assert.NotContains(t, wire, "bad-guid")
1508
+ assert.Empty(t, registry.Owners("bad-guid"))
1509
+ assert.Equal(t, int64(0), job.retries.Load())
1510
+ },
1511
+ },
1512
+ "disappeared scope is removed through chartengine lifecycle and releases owner": {
1513
+ run: func(t *testing.T) {
1514
+ store := metrix.NewCollectorStore()
1515
+ emitScope := true
1516
+ mod := &mockModuleV2{
1517
+ store: store,
1518
+ template: chartTemplateV2ExpireAfterOne(),
1519
+ collectFunc: func(context.Context) error {
1520
+ if emitScope {
1521
+ store.Write().SnapshotMeter("apache").WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1522
+ }
1523
+ return nil
1524
+ },
1525
+ }
1526
+
1527
+ var out bytes.Buffer
1528
+ registry := vnoderegistry.New()
1529
+ job := NewJobV2(JobV2Config{
1530
+ PluginName: pluginName,
1531
+ Name: jobName,
1532
+ ModuleName: modName,
1533
+ FullName: modName + "_" + jobName,
1534
+ Module: mod,
1535
+ Out: &out,
1536
+ UpdateEvery: 1,
1537
+ VnodeRegistry: registry,
1538
+ })
1539
+ require.NoError(t, job.AutoDetection())
1540
+
1541
+ job.runOnce()
1542
+ require.Contains(t, out.String(), `HOST_DEFINE 'guid-a' 'host-a'`)
1543
+ require.NotEmpty(t, registry.Owners("guid-a"))
1544
+ require.NotNil(t, job.scopeStates["scope-a"])
1545
+
1546
+ out.Reset()
1547
+ emitScope = false
1548
+ job.runOnce()
1549
+
1550
+ wire := out.String()
1551
+ assert.Contains(t, wire, `HOST 'guid-a'`)
1552
+ assert.Contains(t, wire, "obsolete")
1553
+ assert.Empty(t, registry.Owners("guid-a"))
1554
+ assert.Nil(t, job.scopeStates["scope-a"])
1555
+ },
1556
+ },
1557
+ "disappeared zero-action scope is removed without registry owner": {
1558
+ run: func(t *testing.T) {
1559
+ store := metrix.NewCollectorStore()
1560
+ emitScope := true
1561
+ mod := &mockModuleV2{
1562
+ store: store,
1563
+ template: chartTemplateV2(),
1564
+ collectFunc: func(context.Context) error {
1565
+ if emitScope {
1566
+ store.Write().SnapshotMeter("apache").WithHostScope(scopeA).Gauge("workers_idle").Observe(7)
1567
+ }
1568
+ return nil
1569
+ },
1570
+ }
1571
+
1572
+ var out bytes.Buffer
1573
+ registry := vnoderegistry.New()
1574
+ job := NewJobV2(JobV2Config{
1575
+ PluginName: pluginName,
1576
+ Name: jobName,
1577
+ ModuleName: modName,
1578
+ FullName: modName + "_" + jobName,
1579
+ Module: mod,
1580
+ Out: &out,
1581
+ UpdateEvery: 1,
1582
+ VnodeRegistry: registry,
1583
+ })
1584
+ require.NoError(t, job.AutoDetection())
1585
+
1586
+ job.runOnce()
1587
+ assert.Empty(t, out.String())
1588
+ assert.Empty(t, registry.Owners("guid-a"))
1589
+ require.NotNil(t, job.scopeStates["scope-a"])
1590
+ assert.Empty(t, job.scopeStates["scope-a"].host.cleanupCharts)
1591
+
1592
+ emitScope = false
1593
+ job.runOnce()
1594
+
1595
+ assert.Empty(t, out.String())
1596
+ assert.Empty(t, registry.Owners("guid-a"))
1597
+ assert.Nil(t, job.scopeStates["scope-a"])
1598
+ },
1599
+ },
1600
+ "default scope removal runs while explicit scope remains": {
1601
+ run: func(t *testing.T) {
1602
+ store := metrix.NewCollectorStore()
1603
+ emitDefault := true
1604
+ mod := &mockModuleV2{
1605
+ store: store,
1606
+ template: chartTemplateV2ExpireAfterOne(),
1607
+ collectFunc: func(context.Context) error {
1608
+ meter := store.Write().SnapshotMeter("apache")
1609
+ if emitDefault {
1610
+ meter.Gauge("workers_busy").Observe(1)
1611
+ }
1612
+ meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1613
+ return nil
1614
+ },
1615
+ }
1616
+
1617
+ var out bytes.Buffer
1618
+ job := newTestJobV2(mod, &out)
1619
+ require.NoError(t, job.AutoDetection())
1620
+
1621
+ job.runOnce()
1622
+ require.NotNil(t, job.scopeStates[defaultHostScopeKey])
1623
+ require.NotNil(t, job.scopeStates["scope-a"])
1624
+
1625
+ out.Reset()
1626
+ emitDefault = false
1627
+ job.runOnce()
1628
+
1629
+ wire := out.String()
1630
+ assert.Contains(t, wire, `HOST ''`)
1631
+ assert.Contains(t, wire, "obsolete")
1632
+ assert.Nil(t, job.scopeStates[defaultHostScopeKey])
1633
+ assert.NotNil(t, job.scopeStates["scope-a"])
1634
+ },
1635
+ },
1636
+ "per-scope commit failure does not block peer scope": {
1637
+ run: func(t *testing.T) {
1638
+ store := metrix.NewCollectorStore()
1639
+ mod := &mockModuleV2{
1640
+ store: store,
1641
+ template: chartTemplateV2(),
1642
+ collectFunc: func(context.Context) error {
1643
+ meter := store.Write().SnapshotMeter("apache")
1644
+ meter.Gauge("workers_busy").Observe(1)
1645
+ meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1646
+ return nil
1647
+ },
1648
+ }
1649
+
1650
+ var out bytes.Buffer
1651
+ registry := vnoderegistry.New()
1652
+ job := NewJobV2(JobV2Config{
1653
+ PluginName: pluginName,
1654
+ Name: jobName,
1655
+ ModuleName: modName,
1656
+ FullName: modName + "_" + jobName,
1657
+ Module: mod,
1658
+ Out: &out,
1659
+ UpdateEvery: 1,
1660
+ VnodeRegistry: registry,
1661
+ })
1662
+ require.NoError(t, job.AutoDetection())
1663
+
1664
+ prepared, ok := job.collectAndEmit(0)
1665
+ require.True(t, ok)
1666
+ require.Len(t, prepared.scopes, 2)
1667
+ for _, scope := range prepared.scopes {
1668
+ if scope.scope.scopeKey == "scope-a" {
1669
+ scope.scope.engine.ResetMaterialized()
1670
+ }
1671
+ }
1672
+
1673
+ require.NoError(t, job.finishPreparedEmission(prepared))
1674
+
1675
+ wire := out.String()
1676
+ assert.Contains(t, wire, `HOST ''`)
1677
+ assert.Contains(t, wire, "SET 'busy' = 1")
1678
+ assert.NotContains(t, wire, "guid-a")
1679
+ assert.Empty(t, registry.Owners("guid-a"))
1680
+ assert.NotNil(t, job.scopeStates[defaultHostScopeKey])
1681
+ },
1682
+ },
1683
+ "cleanup apply failure on one scope does not block peer cleanup": {
1684
+ run: func(t *testing.T) {
1685
+ store := metrix.NewCollectorStore()
1686
+ mod := &mockModuleV2{store: store, template: chartTemplateV2()}
1687
+ var out bytes.Buffer
1688
+ job := newTestJobV2(mod, &out)
1689
+ require.NoError(t, job.AutoDetection())
1690
+
1691
+ okMeta := chartengine.ChartMeta{
1692
+ Title: "OK",
1693
+ Family: "Workers",
1694
+ Context: "workers_ok",
1695
+ Units: "workers",
1696
+ Type: chartengine.ChartTypeLine,
1697
+ Priority: chartengine.Priority,
1698
+ }
1699
+ badMeta := okMeta
1700
+ badMeta.Title = "Bad"
1701
+ job.scopeStates = map[string]*jobV2ScopeState{
1702
+ defaultHostScopeKey: {
1703
+ scopeKey: defaultHostScopeKey,
1704
+ host: jobV2HostState{
1705
+ cleanupOwner: jobV2HostRef{kind: jobV2HostGlobal},
1706
+ cleanupCharts: map[string]chartengine.ChartMeta{
1707
+ "workers_ok": okMeta,
1708
+ },
1709
+ },
1710
+ },
1711
+ "bad": {
1712
+ scopeKey: "bad",
1713
+ scope: scopeA,
1714
+ host: jobV2HostState{
1715
+ cleanupOwner: jobV2HostRef{kind: jobV2HostGlobal},
1716
+ cleanupCharts: map[string]chartengine.ChartMeta{
1717
+ strings.Repeat("x", 1300): badMeta,
1718
+ },
1719
+ },
1720
+ },
1721
+ }
1722
+
1723
+ job.Cleanup()
1724
+
1725
+ assert.Contains(t, out.String(), "workers_ok")
1726
+ assert.Contains(t, out.String(), "obsolete")
1727
+ assert.Empty(t, job.scopeStates)
1728
+ },
1729
+ },
1730
+ "panic after scoped runtime samples resets aggregator and in-flight scope": {
1731
+ run: func(t *testing.T) {
1732
+ store := metrix.NewCollectorStore()
1733
+ mod := &mockModuleV2{
1734
+ store: store,
1735
+ template: chartTemplateV2(),
1736
+ collectFunc: func(context.Context) error {
1737
+ meter := store.Write().SnapshotMeter("apache")
1738
+ meter.Gauge("workers_busy").Observe(1)
1739
+ meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1740
+ return nil
1741
+ },
1742
+ }
1743
+
1744
+ var out bytes.Buffer
1745
+ registry := vnoderegistry.New()
1746
+ job := NewJobV2(JobV2Config{
1747
+ PluginName: pluginName,
1748
+ Name: jobName,
1749
+ ModuleName: modName,
1750
+ FullName: modName + "_" + jobName,
1751
+ Module: mod,
1752
+ Out: &out,
1753
+ UpdateEvery: 1,
1754
+ VnodeRegistry: registry,
1755
+ })
1756
+ require.NoError(t, job.AutoDetection())
1757
+ job.api = netdataapi.New(writeFunc(func(p []byte) (int, error) {
1758
+ if bytes.Contains(p, []byte("HOST_DEFINE 'guid-a'")) {
1759
+ panic("boom")
1760
+ }
1761
+ return job.buf.Write(p)
1762
+ }))
1763
+
1764
+ job.runOnce()
1765
+
1766
+ assert.True(t, job.Panicked())
1767
+ assert.Empty(t, out.String())
1768
+ assert.Empty(t, registry.Owners(scopeA.GUID))
1769
+ value, ok := job.runtimeStore.Read(metrix.ReadRaw()).Value("netdata.go.plugin.framework.chartengine.build_success_total", nil)
1770
+ if ok {
1771
+ assert.Zero(t, value)
1772
+ }
1773
+
1774
+ job.api = netdataapi.New(job.buf)
1775
+ out.Reset()
1776
+ job.runOnce()
1777
+
1778
+ assert.False(t, job.Panicked())
1779
+ assert.Contains(t, out.String(), `HOST_DEFINE 'guid-a' 'host-a'`)
1780
+ assert.NotEmpty(t, registry.Owners(scopeA.GUID))
1781
+ },
1782
+ },
1783
+ }
1784
+
1785
+ for name, tc := range cases {
1786
+ t.Run(name, tc.run)
1787
+ }
1788
+}
1789
+
1790
+func assertContainsInOrder(t *testing.T, s string, parts ...string) {
1791
+ t.Helper()
1792
+ offset := 0
1793
+ for _, part := range parts {
1794
+ idx := strings.Index(s[offset:], part)
1795
+ require.NotEqualf(t, -1, idx, "expected %q after offset %d", part, offset)
1796
+ offset += idx + len(part)
1797
+ }
1798
}
1799
1800
func TestJobV2CleanupUsesLastSuccessfulHostAfterFailedHostSwitch(t *testing.T) {
@@ -1018,8 +1838,7 @@ func TestJobV2CleanupUsesLastSuccessfulHostAfterFailedHostSwitch(t *testing.T) {
1838
1839
CHART 'module_job.workers_busy' '' 'Workers Busy' 'workers' 'Workers' 'workers_busy' 'line' '%d' '1' 'obsolete' 'plugin' 'module'`, chartengine.Priority))
1840
assert.NotContains(t, wire, "HOST 'node-guid-b'")
1021
- assert.Empty(t, job.hostState.cleanupCharts)
1022
- assert.False(t, job.hostState.cleanupOwner.isSet())
1841
+ assert.Empty(t, job.scopeStates)
1842
}
1843
1844
func TestJobV2EmptyHostSwitchDoesNotKeepReloadingEngine(t *testing.T) {
@@ -1046,8 +1865,8 @@ func TestJobV2EmptyHostSwitchDoesNotKeepReloadingEngine(t *testing.T) {
1865
1866
job.runOnce()
1867
require.Equal(t, 1, mod.templateCalls)
1049
- require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, job.hostState.engineHost)
1050
- require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, job.hostState.cleanupOwner)
1868
+ require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.engineHost)
1869
+ require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1870
1871
out.Reset()
1872
emitValue = false
@@ -1058,15 +1877,15 @@ func TestJobV2EmptyHostSwitchDoesNotKeepReloadingEngine(t *testing.T) {
1877
job.runOnce()
1878
assert.Equal(t, "", out.String())
1879
require.Equal(t, 1, mod.templateCalls)
1061
- require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, job.hostState.engineHost)
1062
- require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, job.hostState.cleanupOwner)
1880
+ require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, requireDefaultScopeState(t, job).host.engineHost)
1881
+ require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1882
1883
out.Reset()
1884
job.runOnce()
1885
assert.Equal(t, "", out.String())
1886
assert.Equal(t, 1, mod.templateCalls)
1068
- assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, job.hostState.engineHost)
1069
- assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, job.hostState.cleanupOwner)
1887
+ assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, requireDefaultScopeState(t, job).host.engineHost)
1888
+ assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1889
}
1890
1891
func TestJobV2CleanupDoesNotSuppressGlobalCleanupForDifferentStaleVnode(t *testing.T) {
@@ -1150,6 +1969,52 @@ func TestJobV2CleanupUsesPreModuleCleanupSnapshotForStaleSuppression(t *testing.
1969
assert.True(t, mod.cleaned)
1970
}
1971
1972
+func TestJobV2CleanupDoesNotSuppressExplicitScopeForStaleJobVnode(t *testing.T) {
1973
+ mod := &mockModuleV2{
1974
+ store: metrix.NewCollectorStore(),
1975
+ template: chartTemplateV2(),
1976
+ }
1977
+
1978
+ var out bytes.Buffer
1979
+ job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1980
+ Hostname: "node-host",
1981
+ GUID: "node-guid",
1982
+ Labels: map[string]string{
1983
+ "_node_stale_after_seconds": "60",
1984
+ },
1985
+ })
1986
+ require.NoError(t, job.AutoDetection())
1987
+
1988
+ job.scopeStates = map[string]*jobV2ScopeState{
1989
+ "scope-a": {
1990
+ scopeKey: "scope-a",
1991
+ scope: metrix.HostScope{
1992
+ ScopeKey: "scope-a",
1993
+ GUID: "node-guid",
1994
+ Hostname: "scoped-host",
1995
+ },
1996
+ host: jobV2HostState{
1997
+ cleanupOwner: jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid"},
1998
+ cleanupCharts: map[string]chartengine.ChartMeta{
1999
+ "workers_busy": {
2000
+ Title: "Workers Busy",
2001
+ Family: "Workers",
2002
+ Context: "workers_busy",
2003
+ Units: "workers",
2004
+ Type: chartengine.ChartTypeLine,
2005
+ Priority: chartengine.Priority,
2006
+ },
2007
+ },
2008
+ },
2009
+ },
2010
+ }
2011
+
2012
+ job.Cleanup()
2013
+
2014
+ assert.Contains(t, out.String(), `HOST 'node-guid'`)
2015
+ assert.Contains(t, out.String(), "obsolete")
2016
+}
2017
+
2018
func TestJobV2CleanupNoSuccessfulEmissionsIsNoOp(t *testing.T) {
2019
mod := &mockModuleV2{
2020
store: metrix.NewCollectorStore(),
@@ -1175,9 +2040,12 @@ func TestJobV2CleanupTrackerUsesEffectiveEmittedChartSet(t *testing.T) {
2040
Type: chartengine.ChartTypeLine,
2041
}
2042
1178
- job := &JobV2{}
2043
+ job := &JobV2{scopeStates: map[string]*jobV2ScopeState{
2044
+ defaultHostScopeKey: {scopeKey: defaultHostScopeKey},
2045
+ }}
2046
+ state := requireDefaultScopeState(t, job)
2047
decision := jobV2EmissionDecision{targetHost: jobV2HostRef{kind: jobV2HostGlobal}}
1180
- job.hostState.commitSuccessfulEmission(chartengine.Plan{
2048
+ state.host.commitSuccessfulEmission(chartengine.Plan{
2049
Actions: []chartengine.EngineAction{
2050
chartengine.CreateDimensionAction{
2051
ChartID: "workers_busy",
@@ -1187,11 +2055,11 @@ func TestJobV2CleanupTrackerUsesEffectiveEmittedChartSet(t *testing.T) {
2055
},
2056
}, decision)
2057
1190
- require.Len(t, job.hostState.cleanupCharts, 1)
1191
- assert.Equal(t, meta, job.hostState.cleanupCharts["workers_busy"])
1192
- assert.Equal(t, jobV2HostRef{kind: jobV2HostGlobal}, job.hostState.cleanupOwner)
2058
+ require.Len(t, state.host.cleanupCharts, 1)
2059
+ assert.Equal(t, meta, state.host.cleanupCharts["workers_busy"])
2060
+ assert.Equal(t, jobV2HostRef{kind: jobV2HostGlobal}, state.host.cleanupOwner)
2061
1194
- job.hostState.commitSuccessfulEmission(chartengine.Plan{
2062
+ state.host.commitSuccessfulEmission(chartengine.Plan{
2063
Actions: []chartengine.EngineAction{
2064
chartengine.RemoveChartAction{
2065
ChartID: "workers_busy",
@@ -1200,7 +2068,7 @@ func TestJobV2CleanupTrackerUsesEffectiveEmittedChartSet(t *testing.T) {
2068
},
2069
}, decision)
2070
1203
- assert.Empty(t, job.hostState.cleanupCharts)
2071
+ assert.Empty(t, state.host.cleanupCharts)
2072
}
2073
2074
func TestJobV2StopBeforeStartDoesNotBlock(t *testing.T) {
src/go/plugin/framework/vnoderegistry/registry.go
new
+315
@@ -0,0 +1,315 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+// Package vnoderegistry tracks v2 vnode HOST_DEFINE metadata shared across jobs.
4
+package vnoderegistry
5
+
6
+import (
7
+ "fmt"
8
+ "maps"
9
+ "slices"
10
+ "sort"
11
+ "strconv"
12
+ "strings"
13
+ "sync"
14
+
15
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
17
+)
18
+
19
+const maxReportedMetadataStatesPerGUID = 64
20
+
21
+// Owner identifies one runtime owner of a vnode GUID.
22
+//
23
+// Jobruntime v2 uses stable per-job/per-scope owner IDs so a job can release
24
+// registry ownership after it has emitted cleanup for the corresponding scope.
25
+type Owner string
26
+
27
+// Registration reports the result of registering vnode metadata for an owner.
28
+type Registration struct {
29
+ // Info is the metadata retained by the registry after registration.
30
+ Info netdataapi.HostInfo
31
+
32
+ // Previous is set when an existing GUID's metadata was updated.
33
+ Previous netdataapi.HostInfo
34
+
35
+ // NeedDefine is true when this registration created or updated the registry entry.
36
+ NeedDefine bool
37
+
38
+ // OwnerAdded is true when this call added a new owner record.
39
+ OwnerAdded bool
40
+
41
+ // MetadataUpdated is true when Info replaced previously retained metadata.
42
+ MetadataUpdated bool
43
+
44
+ // UpdateFirstSeen is true only for the first occurrence of a distinct metadata
45
+ // transition. Callers should use this to avoid log spam.
46
+ UpdateFirstSeen bool
47
+
48
+ revision uint64
49
+ previousRevision uint64
50
+}
51
+
52
+type Registry struct {
53
+ mu sync.Mutex
54
+ entries map[string]*entry
55
+}
56
+
57
+type entry struct {
58
+ info netdataapi.HostInfo
59
+ revision uint64
60
+ owners map[Owner]struct{}
61
+ reportedStates map[string]struct{}
62
+ reportedOrder []string
63
+}
64
+
65
+// New returns an empty concurrency-safe vnode registry.
66
+func New() *Registry {
67
+ return &Registry{entries: make(map[string]*entry)}
68
+}
69
+
70
+// Register records that owner emits metrics under info.GUID.
71
+//
72
+// New metadata for an existing GUID replaces the retained metadata. This keeps
73
+// runtime vnode updates simple: callers should log MetadataUpdated as a warning
74
+// because repeated conflicting writers can still cause metadata flip-flop.
75
+func (r *Registry) Register(owner Owner, info netdataapi.HostInfo) (Registration, error) {
76
+ if r == nil {
77
+ return Registration{}, fmt.Errorf("vnoderegistry: nil registry")
78
+ }
79
+ owner = Owner(strings.TrimSpace(string(owner)))
80
+ if owner == "" {
81
+ return Registration{}, fmt.Errorf("vnoderegistry: owner is required")
82
+ }
83
+ info, err := chartemit.PrepareHostInfo(info)
84
+ if err != nil {
85
+ return Registration{}, fmt.Errorf("vnoderegistry: %w", err)
86
+ }
87
+
88
+ r.mu.Lock()
89
+ defer r.mu.Unlock()
90
+
91
+ if r.entries == nil {
92
+ r.entries = make(map[string]*entry)
93
+ }
94
+
95
+ ent, ok := r.entries[info.GUID]
96
+ if !ok {
97
+ ent = &entry{
98
+ info: cloneHostInfo(info),
99
+ revision: 1,
100
+ owners: map[Owner]struct{}{owner: {}},
101
+ reportedStates: make(map[string]struct{}),
102
+ }
103
+ r.entries[info.GUID] = ent
104
+ return Registration{
105
+ Info: cloneHostInfo(ent.info),
106
+ NeedDefine: true,
107
+ OwnerAdded: true,
108
+ revision: ent.revision,
109
+ }, nil
110
+ }
111
+
112
+ _, hadOwner := ent.owners[owner]
113
+ ent.owners[owner] = struct{}{}
114
+
115
+ result := Registration{
116
+ Info: cloneHostInfo(ent.info),
117
+ OwnerAdded: !hadOwner,
118
+ revision: ent.revision,
119
+ }
120
+ if hostInfoEqual(ent.info, info) {
121
+ return result, nil
122
+ }
123
+
124
+ previous := cloneHostInfo(ent.info)
125
+ previousRevision := ent.revision
126
+ ent.info = cloneHostInfo(info)
127
+ ent.revision++
128
+ result.Info = cloneHostInfo(ent.info)
129
+ result.Previous = previous
130
+ result.revision = ent.revision
131
+ result.previousRevision = previousRevision
132
+ result.NeedDefine = true
133
+ result.MetadataUpdated = true
134
+
135
+ updateKey := hostInfoFingerprint(info)
136
+ if markReportedState(ent, updateKey) {
137
+ result.UpdateFirstSeen = true
138
+ }
139
+ return result, nil
140
+}
141
+
142
+// Rollback undoes a registration that has not been emitted successfully.
143
+//
144
+// Rollback is best-effort: if another registration changed the same GUID after
145
+// reg, the metadata restore is skipped to avoid undoing a later writer.
146
+func (r *Registry) Rollback(owner Owner, reg Registration) {
147
+ if r == nil {
148
+ return
149
+ }
150
+ owner = Owner(strings.TrimSpace(string(owner)))
151
+ guid := strings.TrimSpace(reg.Info.GUID)
152
+ if owner == "" || guid == "" {
153
+ return
154
+ }
155
+
156
+ r.mu.Lock()
157
+ defer r.mu.Unlock()
158
+
159
+ ent, ok := r.entries[guid]
160
+ if !ok {
161
+ return
162
+ }
163
+ if reg.OwnerAdded {
164
+ delete(ent.owners, owner)
165
+ }
166
+ if reg.MetadataUpdated && ent.revision == reg.revision && hostInfoEqual(ent.info, reg.Info) {
167
+ ent.info = cloneHostInfo(reg.Previous)
168
+ ent.revision = reg.previousRevision
169
+ }
170
+ if len(ent.owners) == 0 {
171
+ delete(r.entries, guid)
172
+ }
173
+}
174
+
175
+// Release removes one owner record for guid. It returns true when the GUID entry
176
+// was removed because no owners remain.
177
+func (r *Registry) Release(owner Owner, guid string) bool {
178
+ if r == nil {
179
+ return false
180
+ }
181
+ owner = Owner(strings.TrimSpace(string(owner)))
182
+ guid = strings.TrimSpace(guid)
183
+ if owner == "" || guid == "" {
184
+ return false
185
+ }
186
+
187
+ r.mu.Lock()
188
+ defer r.mu.Unlock()
189
+
190
+ ent, ok := r.entries[guid]
191
+ if !ok {
192
+ return false
193
+ }
194
+ delete(ent.owners, owner)
195
+ if len(ent.owners) > 0 {
196
+ return false
197
+ }
198
+ delete(r.entries, guid)
199
+ return true
200
+}
201
+
202
+// Lookup returns the retained metadata for guid.
203
+func (r *Registry) Lookup(guid string) (netdataapi.HostInfo, bool) {
204
+ if r == nil {
205
+ return netdataapi.HostInfo{}, false
206
+ }
207
+ guid = strings.TrimSpace(guid)
208
+ if guid == "" {
209
+ return netdataapi.HostInfo{}, false
210
+ }
211
+
212
+ r.mu.Lock()
213
+ defer r.mu.Unlock()
214
+
215
+ ent, ok := r.entries[guid]
216
+ if !ok {
217
+ return netdataapi.HostInfo{}, false
218
+ }
219
+ return cloneHostInfo(ent.info), true
220
+}
221
+
222
+// Owners returns the sorted owner IDs currently registered for guid.
223
+func (r *Registry) Owners(guid string) []Owner {
224
+ if r == nil {
225
+ return nil
226
+ }
227
+ guid = strings.TrimSpace(guid)
228
+ if guid == "" {
229
+ return nil
230
+ }
231
+
232
+ r.mu.Lock()
233
+ defer r.mu.Unlock()
234
+
235
+ ent, ok := r.entries[guid]
236
+ if !ok {
237
+ return nil
238
+ }
239
+ owners := make([]Owner, 0, len(ent.owners))
240
+ for owner := range ent.owners {
241
+ owners = append(owners, owner)
242
+ }
243
+ slices.Sort(owners)
244
+ return owners
245
+}
246
+
247
+// Len returns the number of retained GUID entries.
248
+func (r *Registry) Len() int {
249
+ if r == nil {
250
+ return 0
251
+ }
252
+ r.mu.Lock()
253
+ defer r.mu.Unlock()
254
+ return len(r.entries)
255
+}
256
+
257
+func cloneHostInfo(info netdataapi.HostInfo) netdataapi.HostInfo {
258
+ return netdataapi.HostInfo{
259
+ GUID: info.GUID,
260
+ Hostname: info.Hostname,
261
+ Labels: maps.Clone(info.Labels),
262
+ }
263
+}
264
+
265
+func hostInfoEqual(left, right netdataapi.HostInfo) bool {
266
+ return left.GUID == right.GUID &&
267
+ left.Hostname == right.Hostname &&
268
+ maps.Equal(left.Labels, right.Labels)
269
+}
270
+
271
+func hostInfoFingerprint(info netdataapi.HostInfo) string {
272
+ var b strings.Builder
273
+ writeHostInfoFingerprint(&b, info)
274
+ return b.String()
275
+}
276
+
277
+func writeHostInfoFingerprint(b *strings.Builder, info netdataapi.HostInfo) {
278
+ writeFingerprintPart(b, info.GUID)
279
+ writeFingerprintPart(b, info.Hostname)
280
+
281
+ keys := make([]string, 0, len(info.Labels))
282
+ for key := range info.Labels {
283
+ keys = append(keys, key)
284
+ }
285
+ sort.Strings(keys)
286
+ for _, key := range keys {
287
+ writeFingerprintPart(b, key)
288
+ writeFingerprintPart(b, info.Labels[key])
289
+ }
290
+}
291
+
292
+func writeFingerprintPart(b *strings.Builder, value string) {
293
+ b.WriteString(strconv.Itoa(len(value)))
294
+ b.WriteByte(':')
295
+ b.WriteString(value)
296
+ b.WriteByte('\xff')
297
+}
298
+
299
+func markReportedState(ent *entry, key string) bool {
300
+ if ent.reportedStates == nil {
301
+ ent.reportedStates = make(map[string]struct{})
302
+ }
303
+ if _, seen := ent.reportedStates[key]; seen {
304
+ return false
305
+ }
306
+ if len(ent.reportedOrder) >= maxReportedMetadataStatesPerGUID {
307
+ evicted := ent.reportedOrder[0]
308
+ copy(ent.reportedOrder, ent.reportedOrder[1:])
309
+ ent.reportedOrder = ent.reportedOrder[:len(ent.reportedOrder)-1]
310
+ delete(ent.reportedStates, evicted)
311
+ }
312
+ ent.reportedStates[key] = struct{}{}
313
+ ent.reportedOrder = append(ent.reportedOrder, key)
314
+ return true
315
+}
src/go/plugin/framework/vnoderegistry/registry_test.go
new
+285
@@ -0,0 +1,285 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package vnoderegistry
4
+
5
+import (
6
+ "fmt"
7
+ "sync"
8
+ "testing"
9
+
10
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestRegistryScenarios(t *testing.T) {
16
+ cases := map[string]struct {
17
+ run func(t *testing.T)
18
+ }{
19
+ "updates metadata and tracks owners": {
20
+ run: func(t *testing.T) {
21
+ reg := New()
22
+ first := netdataapi.HostInfo{
23
+ GUID: "node-guid",
24
+ Hostname: "node-a",
25
+ Labels: map[string]string{"_hostname": "node-a", "region": "eu"},
26
+ }
27
+ same := netdataapi.HostInfo{
28
+ GUID: "node-guid",
29
+ Hostname: "node-a",
30
+ Labels: map[string]string{"_hostname": "node-a", "region": "eu"},
31
+ }
32
+ conflict := netdataapi.HostInfo{
33
+ GUID: "node-guid",
34
+ Hostname: "node-b",
35
+ Labels: map[string]string{"_hostname": "node-b", "region": "us"},
36
+ }
37
+
38
+ got, err := reg.Register("job-a", first)
39
+ require.NoError(t, err)
40
+ assert.True(t, got.NeedDefine)
41
+ assert.True(t, got.OwnerAdded)
42
+ assert.False(t, got.MetadataUpdated)
43
+ assert.Equal(t, first, got.Info)
44
+
45
+ got, err = reg.Register("job-b", same)
46
+ require.NoError(t, err)
47
+ assert.False(t, got.NeedDefine)
48
+ assert.True(t, got.OwnerAdded)
49
+ assert.False(t, got.MetadataUpdated)
50
+ assert.Equal(t, first, got.Info)
51
+
52
+ got, err = reg.Register("job-c", conflict)
53
+ require.NoError(t, err)
54
+ assert.True(t, got.NeedDefine)
55
+ assert.True(t, got.OwnerAdded)
56
+ assert.True(t, got.MetadataUpdated)
57
+ assert.True(t, got.UpdateFirstSeen)
58
+ assert.Equal(t, conflict, got.Info)
59
+ assert.Equal(t, first, got.Previous)
60
+
61
+ got, err = reg.Register("job-c", conflict)
62
+ require.NoError(t, err)
63
+ assert.False(t, got.NeedDefine)
64
+ assert.False(t, got.OwnerAdded)
65
+ assert.False(t, got.MetadataUpdated)
66
+ assert.False(t, got.UpdateFirstSeen)
67
+
68
+ assert.Equal(t, []Owner{"job-a", "job-b", "job-c"}, reg.Owners("node-guid"))
69
+ assert.False(t, reg.Release("job-a", "node-guid"))
70
+ assert.Equal(t, 1, reg.Len())
71
+ assert.Equal(t, []Owner{"job-b", "job-c"}, reg.Owners("node-guid"))
72
+ assert.False(t, reg.Release("job-b", "node-guid"))
73
+ assert.True(t, reg.Release("job-c", "node-guid"))
74
+ assert.Equal(t, 0, reg.Len())
75
+ },
76
+ },
77
+ "normalizes metadata before compare": {
78
+ run: func(t *testing.T) {
79
+ reg := New()
80
+ first := netdataapi.HostInfo{
81
+ GUID: "node-guid",
82
+ Hostname: "node-a",
83
+ }
84
+ sameWireInfo := netdataapi.HostInfo{
85
+ GUID: " node-guid ",
86
+ Hostname: " node-a ",
87
+ Labels: map[string]string{
88
+ "_hostname": "node-a",
89
+ },
90
+ }
91
+
92
+ got, err := reg.Register("job-a", first)
93
+ require.NoError(t, err)
94
+ assert.True(t, got.NeedDefine)
95
+
96
+ got, err = reg.Register("job-b", sameWireInfo)
97
+ require.NoError(t, err)
98
+ assert.False(t, got.NeedDefine)
99
+ assert.Equal(t, map[string]string{"_hostname": "node-a"}, got.Info.Labels)
100
+ },
101
+ },
102
+ "rollback restores metadata update": {
103
+ run: func(t *testing.T) {
104
+ reg := New()
105
+ first := netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-a"}
106
+ next := netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-b"}
107
+
108
+ _, err := reg.Register("job-a", first)
109
+ require.NoError(t, err)
110
+ got, err := reg.Register("job-b", next)
111
+ require.NoError(t, err)
112
+ require.True(t, got.MetadataUpdated)
113
+ require.True(t, got.OwnerAdded)
114
+
115
+ reg.Rollback("job-b", got)
116
+
117
+ info, ok := reg.Lookup("node-guid")
118
+ require.True(t, ok)
119
+ assert.Equal(t, "node-a", info.Hostname)
120
+ assert.Equal(t, []Owner{"job-a"}, reg.Owners("node-guid"))
121
+ },
122
+ },
123
+ "rollback does not undo newer metadata update": {
124
+ run: func(t *testing.T) {
125
+ reg := New()
126
+ _, err := reg.Register("job-a", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-a"})
127
+ require.NoError(t, err)
128
+ rollback, err := reg.Register("job-b", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-b"})
129
+ require.NoError(t, err)
130
+ _, err = reg.Register("job-c", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-c"})
131
+ require.NoError(t, err)
132
+
133
+ reg.Rollback("job-b", rollback)
134
+
135
+ info, ok := reg.Lookup("node-guid")
136
+ require.True(t, ok)
137
+ assert.Equal(t, "node-c", info.Hostname)
138
+ assert.Equal(t, []Owner{"job-a", "job-c"}, reg.Owners("node-guid"))
139
+ },
140
+ },
141
+ "update warnings are per state and bounded": {
142
+ run: func(t *testing.T) {
143
+ reg := New()
144
+ _, err := reg.Register("job", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-a"})
145
+ require.NoError(t, err)
146
+
147
+ got, err := reg.Register("job", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-b"})
148
+ require.NoError(t, err)
149
+ assert.True(t, got.UpdateFirstSeen)
150
+
151
+ got, err = reg.Register("job", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-a"})
152
+ require.NoError(t, err)
153
+ assert.True(t, got.UpdateFirstSeen)
154
+
155
+ got, err = reg.Register("job", netdataapi.HostInfo{GUID: "node-guid", Hostname: "node-b"})
156
+ require.NoError(t, err)
157
+ assert.False(t, got.UpdateFirstSeen)
158
+
159
+ for i := range maxReportedMetadataStatesPerGUID + 1 {
160
+ _, err = reg.Register("job", netdataapi.HostInfo{
161
+ GUID: "node-guid",
162
+ Hostname: fmt.Sprintf("node-%d", i),
163
+ })
164
+ require.NoError(t, err)
165
+ }
166
+ reg.mu.Lock()
167
+ defer reg.mu.Unlock()
168
+ require.Len(t, reg.entries["node-guid"].reportedOrder, maxReportedMetadataStatesPerGUID)
169
+ require.Len(t, reg.entries["node-guid"].reportedStates, maxReportedMetadataStatesPerGUID)
170
+ },
171
+ },
172
+ }
173
+
174
+ for name, tc := range cases {
175
+ t.Run(name, tc.run)
176
+ }
177
+}
178
+
179
+func TestRegistryValidation(t *testing.T) {
180
+ cases := map[string]struct {
181
+ owner Owner
182
+ info netdataapi.HostInfo
183
+ wantErr string
184
+ }{
185
+ "missing owner": {
186
+ info: netdataapi.HostInfo{GUID: "guid", Hostname: "host"},
187
+ wantErr: "owner is required",
188
+ },
189
+ "missing guid": {
190
+ owner: "job",
191
+ info: netdataapi.HostInfo{Hostname: "host"},
192
+ wantErr: "host guid is required",
193
+ },
194
+ "missing hostname": {
195
+ owner: "job",
196
+ info: netdataapi.HostInfo{GUID: "guid"},
197
+ wantErr: "host hostname is required",
198
+ },
199
+ "unsafe hostname": {
200
+ owner: "job",
201
+ info: netdataapi.HostInfo{GUID: "guid", Hostname: "host\nname"},
202
+ wantErr: "unsupported characters",
203
+ },
204
+ }
205
+
206
+ for name, tc := range cases {
207
+ t.Run(name, func(t *testing.T) {
208
+ reg := New()
209
+ _, err := reg.Register(tc.owner, tc.info)
210
+ require.ErrorContains(t, err, tc.wantErr)
211
+ })
212
+ }
213
+}
214
+
215
+func TestRegistryConcurrentScenarios(t *testing.T) {
216
+ cases := map[string]struct {
217
+ run func(t *testing.T)
218
+ }{
219
+ "registration": {
220
+ run: func(t *testing.T) {
221
+ reg := New()
222
+ first := netdataapi.HostInfo{
223
+ GUID: "node-guid",
224
+ Hostname: "node",
225
+ }
226
+
227
+ const owners = 64
228
+ var wg sync.WaitGroup
229
+ errs := make(chan error, owners)
230
+ for i := range owners {
231
+ wg.Go(func() {
232
+ info := first
233
+ if i%2 == 1 {
234
+ info.Hostname = "node-conflict"
235
+ }
236
+ _, err := reg.Register(Owner(fmt.Sprintf("job-%d", i)), info)
237
+ errs <- err
238
+ })
239
+ }
240
+ wg.Wait()
241
+ close(errs)
242
+ for err := range errs {
243
+ require.NoError(t, err)
244
+ }
245
+
246
+ info, ok := reg.Lookup("node-guid")
247
+ require.True(t, ok)
248
+ require.NotEmpty(t, info.Hostname)
249
+ require.Len(t, reg.Owners("node-guid"), owners)
250
+ },
251
+ },
252
+ "register and release": {
253
+ run: func(t *testing.T) {
254
+ reg := New()
255
+ const owners = 64
256
+
257
+ var wg sync.WaitGroup
258
+ errs := make(chan error, owners)
259
+ for i := range owners {
260
+ wg.Go(func() {
261
+ owner := Owner(fmt.Sprintf("job-%d", i))
262
+ _, err := reg.Register(owner, netdataapi.HostInfo{
263
+ GUID: "node-guid",
264
+ Hostname: "node",
265
+ })
266
+ errs <- err
267
+ reg.Release(owner, "node-guid")
268
+ })
269
+ }
270
+ wg.Wait()
271
+ close(errs)
272
+ for err := range errs {
273
+ require.NoError(t, err)
274
+ }
275
+
276
+ assert.Empty(t, reg.Owners("node-guid"))
277
+ assert.Equal(t, 0, reg.Len())
278
+ },
279
+ },
280
+ }
281
+
282
+ for name, tc := range cases {
283
+ t.Run(name, tc.run)
284
+ }
285
+}
src/go/plugin/go.d/collector/azure_monitor/collector.go
+9
@@ -78,6 +78,11 @@ type Collector struct {
78
79
discovery discoveryState
80
81
+ tagsColumnMissingWarned bool
82
+ tagsColumnMissingWarnedAt uint64
83
+ tagsWrongShapeWarned bool
84
+ tagsWrongShapeWarnedAt uint64
85
+
86
now func() time.Time
87
88
newResourceGraph func(subscriptionID string, cred azcore.TokenCredential, cloud azcloud.Configuration) (resourceGraphClient, error)
@@ -101,6 +106,10 @@ func (c *Collector) Init(ctx context.Context) error {
106
c.runtime = nil
107
c.observations = nil
108
c.discovery = discoveryState{}
109
+ c.tagsColumnMissingWarned = false
110
+ c.tagsColumnMissingWarnedAt = 0
111
+ c.tagsWrongShapeWarned = false
112
+ c.tagsWrongShapeWarnedAt = 0
113
114
return nil
115
}
src/go/plugin/go.d/collector/azure_monitor/collector_runtime.go
+19
-12
@@ -11,9 +11,10 @@ import (
11
)
12
13
type collectorRuntime struct {
14
- Profiles []*profileRuntime
15
- ChartTemplateYAML string
16
- Instruments map[string]*instrumentRuntime
14
+ Profiles []*profileRuntime
15
+ ChartTemplateYAML string
16
+ Instruments map[string]*instrumentRuntime
17
+ WorkloadResourceTagKey string
18
}
19
20
type profileRuntime struct {
@@ -46,25 +47,28 @@ type instrumentRuntime struct {
47
Counter metrix.SnapshotCounterVec
48
}
49
49
-func (i *instrumentRuntime) observe(labelValues []string, value float64) {
50
+func (i *instrumentRuntime) observe(scope metrix.HostScope, labelValues []string, value float64) {
51
if i == nil {
52
return
53
}
54
switch i.Kind {
55
case azureprofiles.SeriesKindCounter:
55
- i.Counter.WithLabelValues(labelValues...).ObserveTotal(value)
56
+ i.Counter.WithHostScope(scope).WithLabelValues(labelValues...).ObserveTotal(value)
57
default:
57
- i.Gauge.WithLabelValues(labelValues...).Observe(value)
58
+ i.Gauge.WithHostScope(scope).WithLabelValues(labelValues...).Observe(value)
59
}
60
}
61
62
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
63
+ Resources []resourceInfo
64
+ ByType map[string][]resourceInfo
65
+ ByProfile map[string][]resourceInfo
66
+ ExpiresAt time.Time
67
+ FetchedAt time.Time
68
+ FetchCounter uint64
69
+ QueryTagsColumnMissing bool
70
+ QueryTagsWrongShape bool
71
+ UnsafeWorkloadValues map[string]int
72
}
73
74
type resourceTag struct {
@@ -81,6 +85,7 @@ type resourceInfo struct {
85
ResourceGroup string
86
Region string
87
Tags []resourceTag
88
+ HostScope metrix.HostScope
89
}
90
91
func (r resourceInfo) String() string {
@@ -103,6 +108,7 @@ type metricSample struct {
108
Instrument string
109
Kind string
110
Labels metrix.Labels
111
+ Scope metrix.HostScope
112
Value float64
113
}
114
@@ -113,6 +119,7 @@ type queryBatchResult struct {
119
120
type lastObservation struct {
121
instrument string
122
+ scope metrix.HostScope
123
labelValues []string
124
value float64
125
}
src/go/plugin/go.d/collector/azure_monitor/collector_test.go
+916
-11
@@ -6,8 +6,11 @@ import (
6
"context"
7
"encoding/json"
8
"errors"
9
+ "maps"
10
"os"
11
"path/filepath"
12
+ "sort"
13
+ "strconv"
14
"strings"
15
"sync"
16
"testing"
@@ -17,6 +20,7 @@ import (
20
azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
21
"github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azmetrics"
22
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
23
+ "github.com/google/uuid"
24
"github.com/netdata/netdata/go/plugins/pkg/confopt"
25
"github.com/netdata/netdata/go/plugins/pkg/metrix"
26
"github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
@@ -29,14 +33,18 @@ import (
33
)
34
35
var (
32
- dataConfigJSON, _ = os.ReadFile("testdata/config.json")
33
- dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
36
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
37
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
38
+ dataConfigWorkloadJSON, _ = os.ReadFile("testdata/config_workload.json")
39
+ dataConfigWorkloadYAML, _ = os.ReadFile("testdata/config_workload.yaml")
40
)
41
42
func Test_testDataIsValid(t *testing.T) {
43
for name, data := range map[string][]byte{
38
- "dataConfigJSON": dataConfigJSON,
39
- "dataConfigYAML": dataConfigYAML,
44
+ "dataConfigJSON": dataConfigJSON,
45
+ "dataConfigYAML": dataConfigYAML,
46
+ "dataConfigWorkloadJSON": dataConfigWorkloadJSON,
47
+ "dataConfigWorkloadYAML": dataConfigWorkloadYAML,
48
} {
49
require.NotNil(t, data, name)
50
}
@@ -69,6 +77,12 @@ func TestConfigSchema_RuntimeContract(t *testing.T) {
77
assert.NotContains(t, profileProps, "mode_exact")
78
assert.NotContains(t, profileProps, "mode_combined")
79
80
+ virtualNodes := requireMapField(t, properties, "virtual_nodes")
81
+ assert.NotContains(t, virtualNodes, "required")
82
+ virtualNodeProps := requireMapField(t, virtualNodes, "properties")
83
+ byResourceTag := requireMapField(t, virtualNodeProps, "by_resource_tag")
84
+ assert.Equal(t, "string", byResourceTag["type"])
85
+
86
uiSchema := requireMapField(t, doc, "uiSchema")
87
uiProfiles := requireMapField(t, uiSchema, "profiles")
88
_, hasIDs := uiProfiles["ids"]
@@ -81,6 +95,22 @@ func TestConfigSchema_RuntimeContract(t *testing.T) {
95
assert.True(t, hasModeExact)
96
_, hasModeCombined := uiProfiles["mode_combined"]
97
assert.True(t, hasModeCombined)
98
+
99
+ tabs := requireArrayField(t, requireMapField(t, uiSchema, "ui:options"), "tabs")
100
+ var baseFields, vnodeFields []string
101
+ for _, item := range tabs {
102
+ tab, ok := item.(map[string]any)
103
+ require.True(t, ok)
104
+ switch tab["title"] {
105
+ case "Base":
106
+ baseFields = requireStringSliceField(t, tab, "fields")
107
+ case "Virtual Node":
108
+ vnodeFields = requireStringSliceField(t, tab, "fields")
109
+ }
110
+ }
111
+ assert.NotContains(t, baseFields, "vnode")
112
+ assert.NotContains(t, baseFields, "virtual_nodes")
113
+ assert.ElementsMatch(t, []string{"vnode", "virtual_nodes"}, vnodeFields)
114
}
115
116
func TestConfigSchema_ProfileTagHelpText(t *testing.T) {
@@ -94,6 +124,13 @@ func TestConfigSchema_ProfileTagHelpText(t *testing.T) {
124
assert.NotContains(t, schema, "allOf")
125
126
uiSchema := requireMapField(t, doc, "uiSchema")
127
+ discovery := requireMapField(t, uiSchema, "discovery")
128
+ modeQuery := requireMapField(t, discovery, "mode_query")
129
+ kql := requireMapField(t, modeQuery, "kql")
130
+ help, ok := kql["ui:help"].(string)
131
+ require.True(t, ok)
132
+ assert.Contains(t, help, "project `tags`")
133
+
134
uiProfiles := requireMapField(t, uiSchema, "profiles")
135
for _, mode := range []string{"mode_auto", "mode_exact", "mode_combined"} {
136
modeUI := requireMapField(t, uiProfiles, mode)
@@ -107,6 +144,252 @@ func TestConfigSchema_ProfileTagHelpText(t *testing.T) {
144
}
145
}
146
147
+func TestAzureMonitorStaticArtifacts_WorkloadVirtualNodes(t *testing.T) {
148
+ tests := map[string]struct {
149
+ path string
150
+ contains []string
151
+ notContain []string
152
+ }{
153
+ "metadata": {
154
+ path: "metadata.yaml",
155
+ contains: []string{
156
+ "virtual_nodes.by_resource_tag",
157
+ "_vnode_type=azure_workload",
158
+ "azure_monitor:",
159
+ "More alerts appear after enabling workload virtual nodes",
160
+ },
161
+ },
162
+ "stock config": {
163
+ path: "../../config/go.d/azure_monitor.conf",
164
+ contains: []string{
165
+ "subscription_ids:",
166
+ "virtual_nodes:",
167
+ "by_resource_tag: workload",
168
+ "mode_exact:",
169
+ "mode_combined:",
170
+ },
171
+ notContain: []string{
172
+ "subscription_id:",
173
+ "profile_selection_mode",
174
+ },
175
+ },
176
+ }
177
+
178
+ for name, tc := range tests {
179
+ t.Run(name, func(t *testing.T) {
180
+ raw, err := os.ReadFile(tc.path)
181
+ require.NoError(t, err)
182
+ text := string(raw)
183
+ for _, want := range tc.contains {
184
+ assert.Contains(t, text, want)
185
+ }
186
+ for _, unwanted := range tc.notContain {
187
+ assert.NotContains(t, text, unwanted)
188
+ }
189
+ })
190
+ }
191
+
192
+ docs, err := filepath.Glob("integrations/*.md")
193
+ require.NoError(t, err)
194
+ require.Greater(t, len(docs), 1)
195
+ for _, path := range docs {
196
+ raw, err := os.ReadFile(path)
197
+ require.NoError(t, err, path)
198
+ text := string(raw)
199
+ assert.Contains(t, text, "virtual_nodes.by_resource_tag", path)
200
+ assert.Contains(t, text, "_vnode_type=azure_workload", path)
201
+ }
202
+}
203
+
204
+func TestWorkloadScopeScenarios(t *testing.T) {
205
+ tests := map[string]struct {
206
+ tags []resourceTag
207
+ tagKey string
208
+ wantDefault bool
209
+ wantUnsafe string
210
+ wantHostname string
211
+ }{
212
+ "matching tag creates namespaced scope": {
213
+ tags: []resourceTag{{Key: "workload", Value: " Api "}},
214
+ tagKey: "WORKLOAD",
215
+ wantHostname: "Api",
216
+ },
217
+ "missing tag uses default": {
218
+ tags: []resourceTag{{Key: "env", Value: "prod"}},
219
+ tagKey: "workload",
220
+ wantDefault: true,
221
+ },
222
+ "empty tag value uses default": {
223
+ tags: []resourceTag{{Key: "workload", Value: " "}},
224
+ tagKey: "workload",
225
+ wantDefault: true,
226
+ },
227
+ "unsafe hostname uses default and reports value": {
228
+ tags: []resourceTag{{Key: "workload", Value: "api\nprod"}},
229
+ tagKey: "workload",
230
+ wantDefault: true,
231
+ wantUnsafe: "api\nprod",
232
+ },
233
+ }
234
+
235
+ for name, tc := range tests {
236
+ t.Run(name, func(t *testing.T) {
237
+ scope, unsafeValue := workloadHostScope(resourceInfo{Tags: tc.tags}, tc.tagKey)
238
+
239
+ assert.Equal(t, tc.wantUnsafe, unsafeValue)
240
+ if tc.wantDefault {
241
+ assert.True(t, scope.IsDefault())
242
+ return
243
+ }
244
+
245
+ wantGUID := uuid.NewSHA1(uuid.NameSpaceDNS, []byte(azureWorkloadGUIDPrefix+tc.wantHostname)).String()
246
+ assert.Equal(t, wantGUID, scope.GUID)
247
+ assert.Equal(t, wantGUID, scope.ScopeKey)
248
+ assert.Equal(t, tc.wantHostname, scope.Hostname)
249
+ assert.Equal(t, map[string]string{azureWorkloadScopeLabelKey: azureWorkloadScopeLabelValue}, scope.Labels)
250
+ assert.NotEqual(t, uuid.NewSHA1(uuid.NameSpaceDNS, []byte(tc.wantHostname)).String(), scope.GUID)
251
+ })
252
+ }
253
+}
254
+
255
+func TestConfig_WorkloadResourceTagKey(t *testing.T) {
256
+ tests := map[string]struct {
257
+ virtualNodes *VirtualNodesConfig
258
+ wantKey string
259
+ wantNil bool
260
+ }{
261
+ "unset disables workload scoping": {
262
+ wantNil: true,
263
+ },
264
+ "whitespace disables workload scoping": {
265
+ virtualNodes: &VirtualNodesConfig{ByResourceTag: " \t "},
266
+ wantNil: true,
267
+ },
268
+ "normalizes configured tag key": {
269
+ virtualNodes: &VirtualNodesConfig{ByResourceTag: " Workload "},
270
+ wantKey: "workload",
271
+ },
272
+ }
273
+
274
+ for name, tc := range tests {
275
+ t.Run(name, func(t *testing.T) {
276
+ cfg := testConfig()
277
+ cfg.VirtualNodes = tc.virtualNodes
278
+ cfg.applyDefaults()
279
+
280
+ assert.Equal(t, tc.wantKey, cfg.workloadResourceTagKey())
281
+ if tc.wantNil {
282
+ assert.Nil(t, cfg.VirtualNodes)
283
+ return
284
+ }
285
+ require.NotNil(t, cfg.VirtualNodes)
286
+ assert.Equal(t, tc.wantKey, cfg.VirtualNodes.ByResourceTag)
287
+ })
288
+ }
289
+}
290
+
291
+func TestDiscoveryState_WorkloadScopes(t *testing.T) {
292
+ runtime := &collectorRuntime{
293
+ WorkloadResourceTagKey: "workload",
294
+ Profiles: []*profileRuntime{{
295
+ Name: "sql_database",
296
+ ResourceType: "Microsoft.Sql/servers/databases",
297
+ }},
298
+ }
299
+ resources := []resourceInfo{
300
+ {
301
+ SubscriptionID: "sub-1",
302
+ ID: "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-a",
303
+ UID: "uid-a",
304
+ Name: "db-a",
305
+ Type: "Microsoft.Sql/servers/databases",
306
+ ResourceGroup: "rg-a",
307
+ Region: "eastus",
308
+ Tags: []resourceTag{{Key: "workload", Value: "api"}},
309
+ },
310
+ }
311
+
312
+ state := buildDiscoveryState(resources, runtime, time.Unix(0, 0), 300, 7, discoveryFetchResult{})
313
+ require.Len(t, state.Resources, 1)
314
+ scope := state.Resources[0].HostScope
315
+ require.False(t, scope.IsDefault())
316
+ assert.Equal(t, "api", scope.Hostname)
317
+ assert.Equal(t, scope.GUID, scope.ScopeKey)
318
+ assert.Equal(t, state.Resources, state.ByProfile["sql_database"])
319
+
320
+ changed := state.Resources[0]
321
+ changed.HostScope = metrix.HostScope{}
322
+ assert.False(t, equalResourceInfo(state.Resources[0], changed))
323
+}
324
+
325
+func TestParseStrictQueryDiscoveryRow_OptionalTags(t *testing.T) {
326
+ base := map[string]any{
327
+ "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.Sql/servers/sql-a/databases/db-a",
328
+ "name": "db-a",
329
+ "type": "Microsoft.Sql/servers/databases",
330
+ "resourceGroup": "rg-a",
331
+ "location": "eastus",
332
+ }
333
+
334
+ tests := map[string]struct {
335
+ mutate func(map[string]any)
336
+ wantShape queryTagsShape
337
+ wantTags []resourceTag
338
+ wantErrSubstr string
339
+ }{
340
+ "absent tags": {
341
+ wantShape: queryTagsShapeAbsent,
342
+ },
343
+ "map tags": {
344
+ mutate: func(row map[string]any) {
345
+ row["tags"] = map[string]any{"Workload": "api"}
346
+ },
347
+ wantShape: queryTagsShapePresentMap,
348
+ wantTags: []resourceTag{{Key: "workload", Value: "api"}},
349
+ },
350
+ "null tags are projected empty tags": {
351
+ mutate: func(row map[string]any) {
352
+ row["tags"] = nil
353
+ },
354
+ wantShape: queryTagsShapePresentMap,
355
+ },
356
+ "wrong shaped tags do not fail required parsing": {
357
+ mutate: func(row map[string]any) {
358
+ row["tags"] = []any{"not", "a", "map"}
359
+ },
360
+ wantShape: queryTagsShapeWrong,
361
+ },
362
+ "missing required column still fails": {
363
+ mutate: func(row map[string]any) {
364
+ delete(row, "location")
365
+ },
366
+ wantShape: queryTagsShapeAbsent,
367
+ wantErrSubstr: `missing required column "location"`,
368
+ },
369
+ }
370
+
371
+ for name, tc := range tests {
372
+ t.Run(name, func(t *testing.T) {
373
+ row := make(map[string]any, len(base)+1)
374
+ maps.Copy(row, base)
375
+ if tc.mutate != nil {
376
+ tc.mutate(row)
377
+ }
378
+
379
+ resource, shape, err := parseStrictQueryDiscoveryRow(row)
380
+ assert.Equal(t, tc.wantShape, shape)
381
+ if tc.wantErrSubstr != "" {
382
+ require.Error(t, err)
383
+ assert.ErrorContains(t, err, tc.wantErrSubstr)
384
+ return
385
+ }
386
+
387
+ require.NoError(t, err)
388
+ assert.Equal(t, tc.wantTags, resource.Tags)
389
+ })
390
+ }
391
+}
392
+
393
func requireMapField(t *testing.T, m map[string]any, key string) map[string]any {
394
t.Helper()
395
@@ -148,6 +431,10 @@ func TestCollector_ConfigurationSerialize(t *testing.T) {
431
collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
432
}
433
434
+func TestCollector_ConfigurationSerialize_WorkloadVirtualNodes(t *testing.T) {
435
+ collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigWorkloadJSON, dataConfigWorkloadYAML)
436
+}
437
+
438
func TestCollector_Init(t *testing.T) {
439
tests := map[string]struct {
440
cfg Config
@@ -709,6 +996,71 @@ func TestCollector_CollectScenarios(t *testing.T) {
996
}
997
}
998
999
+func TestCollector_WorkloadMode_MixedTaggedAndUntaggedResources_RouteToCorrectScopes(t *testing.T) {
1000
+ now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
1001
+ taggedID := "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a"
1002
+ untaggedID := "/subscriptions/sub-1/resourceGroups/rg-b/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-b"
1003
+
1004
+ rg := &mockResourceGraph{
1005
+ resources: []map[string]any{
1006
+ {
1007
+ "id": taggedID,
1008
+ "name": "pg-a",
1009
+ "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1010
+ "resourceGroup": "rg-a",
1011
+ "location": "eastus",
1012
+ "tags": map[string]any{"workload": "api"},
1013
+ },
1014
+ {
1015
+ "id": untaggedID,
1016
+ "name": "pg-b",
1017
+ "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1018
+ "resourceGroup": "rg-b",
1019
+ "location": "eastus",
1020
+ "tags": map[string]any{"env": "prod"},
1021
+ },
1022
+ },
1023
+ }
1024
+ mx := &mockMetricsClient{
1025
+ queryResponse: azmetrics.QueryResourcesResponse{MetricResults: azmetrics.MetricResults{Values: []azmetrics.MetricData{
1026
+ {
1027
+ ResourceID: ptrString(strings.ToLower(taggedID)),
1028
+ Values: []azmetrics.Metric{
1029
+ metricWithAvg("cpu_percent", now, 21.5),
1030
+ },
1031
+ },
1032
+ {
1033
+ ResourceID: ptrString(strings.ToLower(untaggedID)),
1034
+ Values: []azmetrics.Metric{
1035
+ metricWithAvg("cpu_percent", now, 33.1),
1036
+ },
1037
+ },
1038
+ }}},
1039
+ }
1040
+ c := newTestCollectorWithMocks(rg, mx)
1041
+ c.Config = testConfig()
1042
+ c.Config.VirtualNodes = &VirtualNodesConfig{ByResourceTag: "workload"}
1043
+ c.now = func() time.Time { return now }
1044
+
1045
+ require.NoError(t, c.Init(context.Background()))
1046
+ defaultSeries, err := collecttest.CollectScalarSeries(c, metrix.ReadRaw())
1047
+ require.NoError(t, err)
1048
+ assert.Contains(t, strings.Join(keysFromSeries(defaultSeries), "\n"), `resource_name="pg-b"`)
1049
+ assert.NotContains(t, strings.Join(keysFromSeries(defaultSeries), "\n"), `resource_name="pg-a"`)
1050
+
1051
+ var workloadScope metrix.HostScope
1052
+ for _, resource := range c.discovery.Resources {
1053
+ if resource.Name == "pg-a" {
1054
+ workloadScope = resource.HostScope
1055
+ }
1056
+ }
1057
+ require.False(t, workloadScope.IsDefault())
1058
+
1059
+ scopedSeries := scalarSeriesFromReader(c.store.Read(metrix.ReadRaw(), metrix.ReadHostScope(workloadScope.ScopeKey)))
1060
+ assert.Contains(t, strings.Join(keysFromSeries(scopedSeries), "\n"), `resource_name="pg-a"`)
1061
+ assert.NotContains(t, strings.Join(keysFromSeries(scopedSeries), "\n"), `resource_name="pg-b"`)
1062
+}
1063
+
1064
func TestCollector_RefreshDiscovery_PushesModeFiltersIntoQuery(t *testing.T) {
1065
tests := map[string]struct {
1066
resources []map[string]any
@@ -985,6 +1337,63 @@ template:
1337
assert.Equal(t, []string{"UsedCapacity"}, byInterval["PT5M"].MetricNames)
1338
}
1339
1340
+func TestSamplesFromQueryResponse_UsesResourceHostScope(t *testing.T) {
1341
+ now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
1342
+ scope, unsafeValue := workloadHostScope(resourceInfo{Tags: []resourceTag{{Key: "workload", Value: "api"}}}, "workload")
1343
+ require.Empty(t, unsafeValue)
1344
+
1345
+ tests := map[string]struct {
1346
+ scope metrix.HostScope
1347
+ }{
1348
+ "default scope": {},
1349
+ "workload scope": {
1350
+ scope: scope,
1351
+ },
1352
+ }
1353
+
1354
+ for name, tc := range tests {
1355
+ t.Run(name, func(t *testing.T) {
1356
+ resourceID := "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a"
1357
+ samples := samplesFromQueryResponse(
1358
+ []azmetrics.MetricData{{
1359
+ ResourceID: ptrString(strings.ToLower(resourceID)),
1360
+ Values: []azmetrics.Metric{
1361
+ metricWithAvg("cpu_percent", now, 10),
1362
+ },
1363
+ }},
1364
+ "postgres_flexible",
1365
+ map[string]*metricRuntime{
1366
+ "cpu_percent": {
1367
+ Series: []*seriesRuntime{{
1368
+ Aggregation: "average",
1369
+ Kind: azureprofiles.SeriesKindGauge,
1370
+ Instrument: "postgres_flexible.cpu_percent.average",
1371
+ }},
1372
+ },
1373
+ },
1374
+ map[string]resourceInfo{
1375
+ strings.ToLower(resourceID): {
1376
+ SubscriptionID: "sub-1",
1377
+ ID: resourceID,
1378
+ UID: "uid-a",
1379
+ Name: "pg-a",
1380
+ Type: "Microsoft.DBforPostgreSQL/flexibleServers",
1381
+ ResourceGroup: "rg-a",
1382
+ Region: "eastus",
1383
+ HostScope: tc.scope,
1384
+ },
1385
+ },
1386
+ )
1387
+
1388
+ require.Len(t, samples, 1)
1389
+ assert.Equal(t, tc.scope, samples[0].Scope)
1390
+ if tc.scope.IsDefault() {
1391
+ assert.True(t, samples[0].Scope.IsDefault())
1392
+ }
1393
+ })
1394
+ }
1395
+}
1396
+
1397
func TestCollector_CheckBootstrapProfileScenarios(t *testing.T) {
1398
tests := map[string]struct {
1399
resources []map[string]any
@@ -1183,6 +1592,281 @@ func TestCollector_CheckBootstrapQueryModeScenarios(t *testing.T) {
1592
}
1593
}
1594
1595
+func TestCollector_WorkloadMode_QueryModeTagFallbackState(t *testing.T) {
1596
+ const kql = "resources | project id, name, type, resourceGroup, location"
1597
+
1598
+ tests := map[string]struct {
1599
+ tags any
1600
+ tagsPresent bool
1601
+ wantMissingWarning bool
1602
+ wantWrongShapeWarning bool
1603
+ wantDefaultScope bool
1604
+ wantWorkloadScopeHostname string
1605
+ }{
1606
+ "tags column missing falls back and records warning": {
1607
+ wantMissingWarning: true,
1608
+ wantDefaultScope: true,
1609
+ },
1610
+ "tags wrong shape falls back and records warning": {
1611
+ tagsPresent: true,
1612
+ tags: "not-a-map",
1613
+ wantWrongShapeWarning: true,
1614
+ wantDefaultScope: true,
1615
+ },
1616
+ "configured tag missing in tags map falls back silently": {
1617
+ tagsPresent: true,
1618
+ tags: map[string]any{"env": "prod"},
1619
+ wantDefaultScope: true,
1620
+ },
1621
+ "tags map derives workload scope": {
1622
+ tagsPresent: true,
1623
+ tags: map[string]any{"Workload": "api"},
1624
+ wantWorkloadScopeHostname: "api",
1625
+ },
1626
+ }
1627
+
1628
+ for name, tc := range tests {
1629
+ t.Run(name, func(t *testing.T) {
1630
+ row := map[string]any{
1631
+ "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1632
+ "name": "pg-a",
1633
+ "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1634
+ "resourceGroup": "rg-a",
1635
+ "location": "eastus",
1636
+ }
1637
+ if tc.tagsPresent {
1638
+ row["tags"] = tc.tags
1639
+ }
1640
+
1641
+ rg := &mockResourceGraph{resources: []map[string]any{row}}
1642
+ c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1643
+ c.Config = testConfig()
1644
+ c.Config.VirtualNodes = &VirtualNodesConfig{ByResourceTag: "workload"}
1645
+ c.Config.Profiles.Mode = profilesModeAuto
1646
+ c.Config.Profiles.ModeExact = nil
1647
+ c.Config.Profiles.ModeCombined = nil
1648
+ c.Config.Discovery.Mode = discoveryModeQuery
1649
+ c.Config.Discovery.ModeFilters = nil
1650
+ c.Config.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: kql}
1651
+
1652
+ require.NoError(t, c.Init(context.Background()))
1653
+ require.NoError(t, c.Check(context.Background()))
1654
+
1655
+ require.Len(t, c.discovery.Resources, 1)
1656
+ assert.Equal(t, tc.wantMissingWarning, c.tagsColumnMissingWarned)
1657
+ assert.Equal(t, tc.wantWrongShapeWarning, c.tagsWrongShapeWarned)
1658
+ if tc.wantDefaultScope {
1659
+ assert.True(t, c.discovery.Resources[0].HostScope.IsDefault())
1660
+ return
1661
+ }
1662
+ require.False(t, c.discovery.Resources[0].HostScope.IsDefault())
1663
+ assert.Equal(t, tc.wantWorkloadScopeHostname, c.discovery.Resources[0].HostScope.Hostname)
1664
+ })
1665
+ }
1666
+}
1667
+
1668
+func TestCollector_WorkloadMode_QueryModeWarningWatermarks(t *testing.T) {
1669
+ c := New()
1670
+ runtime := &collectorRuntime{WorkloadResourceTagKey: "workload"}
1671
+
1672
+ state := discoveryState{
1673
+ FetchCounter: 1,
1674
+ QueryTagsColumnMissing: true,
1675
+ QueryTagsWrongShape: true,
1676
+ UnsafeWorkloadValues: map[string]int{
1677
+ "bad\nvalue": 2,
1678
+ },
1679
+ }
1680
+
1681
+ c.warnDiscoveryScopeFallbacks(state, runtime)
1682
+ c.warnDiscoveryScopeFallbacks(state, runtime)
1683
+ assert.True(t, c.tagsColumnMissingWarned)
1684
+ assert.True(t, c.tagsWrongShapeWarned)
1685
+ assert.Equal(t, uint64(1), c.tagsColumnMissingWarnedAt)
1686
+ assert.Equal(t, uint64(1), c.tagsWrongShapeWarnedAt)
1687
+
1688
+ state.FetchCounter = 2
1689
+ c.warnDiscoveryScopeFallbacks(state, runtime)
1690
+ assert.Equal(t, uint64(2), c.tagsColumnMissingWarnedAt)
1691
+ assert.Equal(t, uint64(2), c.tagsWrongShapeWarnedAt)
1692
+}
1693
+
1694
+func TestCollector_WorkloadMode_QueryModeWarningDedupAcrossRefreshes(t *testing.T) {
1695
+ const kql = "resources | project id, name, type, resourceGroup, location"
1696
+
1697
+ tests := map[string]struct {
1698
+ tagsPresent bool
1699
+ tags any
1700
+ wantMissingAt func(*Collector) uint64
1701
+ wantWrongAt func(*Collector) uint64
1702
+ }{
1703
+ "missing tags column": {
1704
+ wantMissingAt: func(c *Collector) uint64 { return c.tagsColumnMissingWarnedAt },
1705
+ },
1706
+ "wrong shaped tags column": {
1707
+ tagsPresent: true,
1708
+ tags: "not-a-map",
1709
+ wantWrongAt: func(c *Collector) uint64 { return c.tagsWrongShapeWarnedAt },
1710
+ },
1711
+ }
1712
+
1713
+ for name, tc := range tests {
1714
+ t.Run(name, func(t *testing.T) {
1715
+ row := map[string]any{
1716
+ "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1717
+ "name": "pg-a",
1718
+ "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1719
+ "resourceGroup": "rg-a",
1720
+ "location": "eastus",
1721
+ }
1722
+ if tc.tagsPresent {
1723
+ row["tags"] = tc.tags
1724
+ }
1725
+
1726
+ rg := &mockResourceGraph{resources: []map[string]any{row}}
1727
+ c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1728
+ c.Config = testConfig()
1729
+ c.Config.VirtualNodes = &VirtualNodesConfig{ByResourceTag: "workload"}
1730
+ c.Config.Profiles.Mode = profilesModeAuto
1731
+ c.Config.Profiles.ModeExact = nil
1732
+ c.Config.Profiles.ModeCombined = nil
1733
+ c.Config.Discovery.Mode = discoveryModeQuery
1734
+ c.Config.Discovery.ModeFilters = nil
1735
+ c.Config.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: kql}
1736
+
1737
+ require.NoError(t, c.Init(context.Background()))
1738
+ require.NoError(t, c.Check(context.Background()))
1739
+ require.Equal(t, uint64(1), c.discovery.FetchCounter)
1740
+ if tc.wantMissingAt != nil {
1741
+ assert.Equal(t, uint64(1), tc.wantMissingAt(c))
1742
+ }
1743
+ if tc.wantWrongAt != nil {
1744
+ assert.Equal(t, uint64(1), tc.wantWrongAt(c))
1745
+ }
1746
+
1747
+ c.warnDiscoveryScopeFallbacks(c.discovery, c.runtime)
1748
+ if tc.wantMissingAt != nil {
1749
+ assert.Equal(t, uint64(1), tc.wantMissingAt(c))
1750
+ }
1751
+ if tc.wantWrongAt != nil {
1752
+ assert.Equal(t, uint64(1), tc.wantWrongAt(c))
1753
+ }
1754
+
1755
+ _, err := c.refreshDiscovery(context.Background(), true)
1756
+ require.NoError(t, err)
1757
+ require.Equal(t, uint64(2), c.discovery.FetchCounter)
1758
+ if tc.wantMissingAt != nil {
1759
+ assert.Equal(t, uint64(2), tc.wantMissingAt(c))
1760
+ }
1761
+ if tc.wantWrongAt != nil {
1762
+ assert.Equal(t, uint64(2), tc.wantWrongAt(c))
1763
+ }
1764
+ })
1765
+ }
1766
+}
1767
+
1768
+func TestCollector_WorkloadMode_UnsafeValueReportResetsPerRefresh(t *testing.T) {
1769
+ runtime := &collectorRuntime{
1770
+ WorkloadResourceTagKey: "workload",
1771
+ Profiles: []*profileRuntime{{
1772
+ Name: "postgres_flexible",
1773
+ ResourceType: "Microsoft.DBforPostgreSQL/flexibleServers",
1774
+ }},
1775
+ }
1776
+ base := resourceInfo{
1777
+ SubscriptionID: "sub-1",
1778
+ ID: "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1779
+ UID: "uid-a",
1780
+ Name: "pg-a",
1781
+ Type: "Microsoft.DBforPostgreSQL/flexibleServers",
1782
+ ResourceGroup: "rg-a",
1783
+ Region: "eastus",
1784
+ }
1785
+ tests := map[string]struct {
1786
+ refreshes []struct {
1787
+ tags []resourceTag
1788
+ wantUnsafe map[string]int
1789
+ }
1790
+ }{
1791
+ "unsafe report is scoped to the current refresh": {
1792
+ refreshes: []struct {
1793
+ tags []resourceTag
1794
+ wantUnsafe map[string]int
1795
+ }{
1796
+ {
1797
+ tags: []resourceTag{{Key: "workload", Value: "bad\none"}},
1798
+ wantUnsafe: map[string]int{"bad\none": 1},
1799
+ },
1800
+ {
1801
+ tags: []resourceTag{{Key: "workload", Value: "bad\ntwo"}},
1802
+ wantUnsafe: map[string]int{"bad\ntwo": 1},
1803
+ },
1804
+ {
1805
+ tags: []resourceTag{{Key: "workload", Value: "api"}},
1806
+ },
1807
+ },
1808
+ },
1809
+ }
1810
+
1811
+ for name, tc := range tests {
1812
+ t.Run(name, func(t *testing.T) {
1813
+ for i, refresh := range tc.refreshes {
1814
+ resource := base
1815
+ resource.Tags = refresh.tags
1816
+ fetchCounter := uint64(i + 1)
1817
+
1818
+ state := buildDiscoveryState([]resourceInfo{resource}, runtime, time.Unix(int64(fetchCounter), 0), 300, fetchCounter, discoveryFetchResult{})
1819
+
1820
+ assert.Equal(t, refresh.wantUnsafe, state.UnsafeWorkloadValues)
1821
+ }
1822
+ })
1823
+ }
1824
+}
1825
+
1826
+func TestCollector_WorkloadMode_DisabledLeavesQueryAndScopesDefault(t *testing.T) {
1827
+ const kql = "resources | project id, name, type, resourceGroup, location"
1828
+
1829
+ tests := map[string]struct {
1830
+ virtualNodes *VirtualNodesConfig
1831
+ }{
1832
+ "unset": {},
1833
+ "whitespace": {
1834
+ virtualNodes: &VirtualNodesConfig{ByResourceTag: " \t "},
1835
+ },
1836
+ }
1837
+
1838
+ for name, tc := range tests {
1839
+ t.Run(name, func(t *testing.T) {
1840
+ rg := &mockResourceGraph{resources: []map[string]any{{
1841
+ "id": "/subscriptions/sub-1/resourceGroups/rg-a/providers/Microsoft.DBforPostgreSQL/flexibleServers/pg-a",
1842
+ "name": "pg-a",
1843
+ "type": "Microsoft.DBforPostgreSQL/flexibleServers",
1844
+ "resourceGroup": "rg-a",
1845
+ "location": "eastus",
1846
+ }}}
1847
+ c := newTestCollectorWithMocks(rg, &mockMetricsClient{})
1848
+ c.Config = testConfig()
1849
+ c.Config.VirtualNodes = tc.virtualNodes
1850
+ c.Config.Profiles.Mode = profilesModeAuto
1851
+ c.Config.Profiles.ModeExact = nil
1852
+ c.Config.Profiles.ModeCombined = nil
1853
+ c.Config.Discovery.Mode = discoveryModeQuery
1854
+ c.Config.Discovery.ModeFilters = nil
1855
+ c.Config.Discovery.ModeQuery = &DiscoveryQueryConfig{KQL: kql}
1856
+
1857
+ require.NoError(t, c.Init(context.Background()))
1858
+ require.NoError(t, c.Check(context.Background()))
1859
+
1860
+ assert.Equal(t, kql, rg.lastQuery())
1861
+ assert.Empty(t, c.runtime.WorkloadResourceTagKey)
1862
+ require.Len(t, c.discovery.Resources, 1)
1863
+ assert.True(t, c.discovery.Resources[0].HostScope.IsDefault())
1864
+ assert.False(t, c.tagsColumnMissingWarned)
1865
+ assert.False(t, c.tagsWrongShapeWarned)
1866
+ })
1867
+ }
1868
+}
1869
+
1870
func TestCollector_InitQueryModeRejectsMalformedRows(t *testing.T) {
1871
const kql = "resources | project id, name, type, resourceGroup, location"
1872
@@ -1454,13 +2138,13 @@ func TestObservationState_PruneStaleResources_RemovesOldProfileMembership(t *tes
2138
Type: "Microsoft.Sql/servers/databases",
2139
}
2140
labels := labelValues(resourceLabels(resource, "sql_database"))
1457
- key := sampleObservationKey("sql.cpu", labels)
2141
+ key := sampleObservationKey("sql.cpu", metrix.HostScope{}, labels)
2142
2143
state := &observationState{
1460
- accumulators: map[string]float64{
2144
+ accumulators: map[observationKey]float64{
2145
key: 10,
2146
},
1463
- lastObserved: map[string]lastObservation{
2147
+ lastObserved: map[observationKey]lastObservation{
2148
key: {
2149
instrument: "sql.cpu",
2150
labelValues: append([]string(nil), labels...),
@@ -1490,13 +2174,13 @@ func TestObservationState_PruneStaleResources_RemovesLabelChurnForSameResource(t
2174
newResource.Name = "db-a-renamed"
2175
2176
labels := labelValues(resourceLabels(oldResource, "sql_database"))
1493
- key := sampleObservationKey("sql.cpu", labels)
2177
+ key := sampleObservationKey("sql.cpu", metrix.HostScope{}, labels)
2178
2179
state := &observationState{
1496
- accumulators: map[string]float64{
2180
+ accumulators: map[observationKey]float64{
2181
key: 10,
2182
},
1499
- lastObserved: map[string]lastObservation{
2183
+ lastObserved: map[observationKey]lastObservation{
2184
key: {
2185
instrument: "sql.cpu",
2186
labelValues: append([]string(nil), labels...),
@@ -1513,6 +2197,192 @@ func TestObservationState_PruneStaleResources_RemovesLabelChurnForSameResource(t
2197
assert.Empty(t, state.accumulators)
2198
}
2199
2200
+func TestObservationState_PruneStaleResources_RespectsScopeBoundary(t *testing.T) {
2201
+ scopeA, unsafeValue := workloadHostScope(resourceInfo{Tags: []resourceTag{{Key: "workload", Value: "api"}}}, "workload")
2202
+ require.Empty(t, unsafeValue)
2203
+ scopeB, unsafeValue := workloadHostScope(resourceInfo{Tags: []resourceTag{{Key: "workload", Value: "worker"}}}, "workload")
2204
+ require.Empty(t, unsafeValue)
2205
+
2206
+ resource := resourceInfo{
2207
+ SubscriptionID: "sub-1",
2208
+ UID: "uid-a",
2209
+ Name: "db-a",
2210
+ ResourceGroup: "rg-a",
2211
+ Region: "eastus",
2212
+ Type: "Microsoft.Sql/servers/databases",
2213
+ HostScope: scopeA,
2214
+ }
2215
+ labels := labelValues(resourceLabels(resource, "sql_database"))
2216
+ keyA := sampleObservationKey("sql.cpu", scopeA, labels)
2217
+ keyB := sampleObservationKey("sql.cpu", scopeB, labels)
2218
+
2219
+ state := &observationState{
2220
+ accumulators: map[observationKey]float64{
2221
+ keyA: 10,
2222
+ keyB: 20,
2223
+ },
2224
+ lastObserved: map[observationKey]lastObservation{
2225
+ keyA: {
2226
+ instrument: "sql.cpu",
2227
+ scope: scopeA,
2228
+ labelValues: append([]string(nil), labels...),
2229
+ value: 10,
2230
+ },
2231
+ keyB: {
2232
+ instrument: "sql.cpu",
2233
+ scope: scopeB,
2234
+ labelValues: append([]string(nil), labels...),
2235
+ value: 20,
2236
+ },
2237
+ },
2238
+ }
2239
+
2240
+ state.pruneStaleResources(map[string][]resourceInfo{
2241
+ "sql_database": {resource},
2242
+ })
2243
+
2244
+ assert.Contains(t, state.lastObserved, keyA)
2245
+ assert.NotContains(t, state.lastObserved, keyB)
2246
+ assert.Contains(t, state.accumulators, keyA)
2247
+ assert.NotContains(t, state.accumulators, keyB)
2248
+}
2249
+
2250
+func TestObservationState_CounterAccumulatorFollowsScopeMobility(t *testing.T) {
2251
+ scopes := map[string]metrix.HostScope{}
2252
+ for _, workload := range []string{"api", "worker", "batch"} {
2253
+ scope, unsafeValue := workloadHostScope(resourceInfo{Tags: []resourceTag{{Key: "workload", Value: workload}}}, "workload")
2254
+ require.Empty(t, unsafeValue)
2255
+ scopes[workload] = scope
2256
+ }
2257
+
2258
+ store := metrix.NewCollectorStore()
2259
+ managed, ok := metrix.AsCycleManagedStore(store)
2260
+ require.True(t, ok)
2261
+ cycle := managed.CycleController()
2262
+ vec := store.Write().SnapshotMeter("").Vec("resource_uid", "subscription_id", "resource_name", "resource_group", "region", "resource_type", "profile")
2263
+ state := &observationState{
2264
+ instruments: map[string]*instrumentRuntime{
2265
+ "sql.transactions": {
2266
+ Kind: azureprofiles.SeriesKindCounter,
2267
+ Counter: vec.Counter("sql.transactions"),
2268
+ },
2269
+ },
2270
+ accumulators: make(map[observationKey]float64),
2271
+ lastObserved: make(map[observationKey]lastObservation),
2272
+ }
2273
+
2274
+ baseResource := resourceInfo{
2275
+ SubscriptionID: "sub-1",
2276
+ UID: "uid-a",
2277
+ Name: "db-a",
2278
+ ResourceGroup: "rg-a",
2279
+ Region: "eastus",
2280
+ Type: "Microsoft.Sql/servers/databases",
2281
+ }
2282
+ steps := map[string]struct {
2283
+ path []struct {
2284
+ workload string
2285
+ value float64
2286
+ }
2287
+ }{
2288
+ "a to b to c to a": {
2289
+ path: []struct {
2290
+ workload string
2291
+ value float64
2292
+ }{
2293
+ {workload: "api", value: 5},
2294
+ {workload: "worker", value: 7},
2295
+ {workload: "batch", value: 11},
2296
+ {workload: "api", value: 13},
2297
+ },
2298
+ },
2299
+ }
2300
+
2301
+ for name, tc := range steps {
2302
+ t.Run(name, func(t *testing.T) {
2303
+ for _, step := range tc.path {
2304
+ scope := scopes[step.workload]
2305
+ resource := baseResource
2306
+ resource.HostScope = scope
2307
+ labels := labelValues(resourceLabels(resource, "sql_database"))
2308
+
2309
+ state.pruneStaleResources(map[string][]resourceInfo{"sql_database": {resource}})
2310
+ cycle.BeginCycle()
2311
+ observed := state.observeSamples([]metricSample{{
2312
+ Instrument: "sql.transactions",
2313
+ Kind: azureprofiles.SeriesKindCounter,
2314
+ Scope: scope,
2315
+ Labels: resourceLabels(resource, "sql_database"),
2316
+ Value: step.value,
2317
+ }})
2318
+ state.reobserveCachedObservations(map[string]bool{}, observed)
2319
+ require.NoError(t, cycle.CommitCycleSuccess())
2320
+
2321
+ key := sampleObservationKey("sql.transactions", scope, labels)
2322
+ require.Len(t, state.accumulators, 1)
2323
+ assert.Equal(t, step.value, state.accumulators[key])
2324
+ assert.Contains(t, state.lastObserved, key)
2325
+ }
2326
+ })
2327
+ }
2328
+}
2329
+
2330
+func TestObservationState_ReobserveUsesStoredScope(t *testing.T) {
2331
+ scope, unsafeValue := workloadHostScope(resourceInfo{Tags: []resourceTag{{Key: "workload", Value: "api"}}}, "workload")
2332
+ require.Empty(t, unsafeValue)
2333
+
2334
+ store := metrix.NewCollectorStore()
2335
+ managed, ok := metrix.AsCycleManagedStore(store)
2336
+ require.True(t, ok)
2337
+ cycle := managed.CycleController()
2338
+ vec := store.Write().SnapshotMeter("").Vec("resource_uid", "subscription_id", "resource_name", "resource_group", "region", "resource_type", "profile")
2339
+ labels := []string{"uid-a", "sub-1", "db-a", "rg-a", "eastus", "Microsoft.Sql/servers/databases", "sql_database"}
2340
+
2341
+ state := &observationState{
2342
+ instruments: map[string]*instrumentRuntime{
2343
+ "sql.cpu": {
2344
+ Kind: azureprofiles.SeriesKindGauge,
2345
+ Gauge: vec.Gauge("sql.cpu"),
2346
+ },
2347
+ },
2348
+ accumulators: make(map[observationKey]float64),
2349
+ lastObserved: map[observationKey]lastObservation{
2350
+ sampleObservationKey("sql.cpu", scope, labels): {
2351
+ instrument: "sql.cpu",
2352
+ scope: scope,
2353
+ labelValues: append([]string(nil), labels...),
2354
+ value: 42,
2355
+ },
2356
+ },
2357
+ }
2358
+
2359
+ cycle.BeginCycle()
2360
+ state.reobserveCachedObservations(map[string]bool{}, map[observationKey]bool{})
2361
+ require.NoError(t, cycle.CommitCycleSuccess())
2362
+
2363
+ _, ok = store.Read(metrix.ReadRaw()).Value("sql.cpu", metrix.Labels{
2364
+ "resource_uid": "uid-a",
2365
+ "subscription_id": "sub-1",
2366
+ "resource_name": "db-a",
2367
+ "resource_group": "rg-a",
2368
+ "region": "eastus",
2369
+ "resource_type": "Microsoft.Sql/servers/databases",
2370
+ "profile": "sql_database",
2371
+ })
2372
+ assert.False(t, ok)
2373
+
2374
+ _, ok = store.Read(metrix.ReadRaw(), metrix.ReadHostScope(scope.ScopeKey)).Value("sql.cpu", metrix.Labels{
2375
+ "resource_uid": "uid-a",
2376
+ "subscription_id": "sub-1",
2377
+ "resource_name": "db-a",
2378
+ "resource_group": "rg-a",
2379
+ "region": "eastus",
2380
+ "resource_type": "Microsoft.Sql/servers/databases",
2381
+ "profile": "sql_database",
2382
+ })
2383
+ assert.True(t, ok)
2384
+}
2385
+
2386
func TestConfig_ValidateDiscoveryContracts(t *testing.T) {
2387
tests := map[string]struct {
2388
cfg Config
@@ -1774,7 +2644,7 @@ template:
2644
`,
2645
})
2646
1777
- _, err := buildCollectorRuntimeFromConfig(profileNames, nil, catalog)
2647
+ _, err := buildCollectorRuntimeFromConfig(profileNames, nil, catalog, "")
2648
require.Error(t, err)
2649
}
2650
@@ -2043,3 +2913,38 @@ func keysFromSeries(series map[string]metrix.SampleValue) []string {
2913
}
2914
return out
2915
}
2916
+
2917
+func scalarSeriesFromReader(reader metrix.Reader) map[string]metrix.SampleValue {
2918
+ out := make(map[string]metrix.SampleValue)
2919
+ reader.ForEachSeries(func(name string, labels metrix.LabelView, value metrix.SampleValue) {
2920
+ out[scalarKeyFromLabelView(name, labels)] = value
2921
+ })
2922
+ return out
2923
+}
2924
+
2925
+func scalarKeyFromLabelView(name string, labels metrix.LabelView) string {
2926
+ if labels == nil || labels.Len() == 0 {
2927
+ return strings.TrimSpace(name)
2928
+ }
2929
+
2930
+ labelsMap := labels.CloneMap()
2931
+ keys := make([]string, 0, len(labelsMap))
2932
+ for key := range labelsMap {
2933
+ keys = append(keys, key)
2934
+ }
2935
+ sort.Strings(keys)
2936
+
2937
+ var b strings.Builder
2938
+ b.WriteString(strings.TrimSpace(name))
2939
+ b.WriteByte('{')
2940
+ for i, key := range keys {
2941
+ if i > 0 {
2942
+ b.WriteByte(',')
2943
+ }
2944
+ b.WriteString(key)
2945
+ b.WriteByte('=')
2946
+ b.WriteString(strconv.Quote(labelsMap[key]))
2947
+ }
2948
+ b.WriteByte('}')
2949
+ return b.String()
2950
+}
src/go/plugin/go.d/collector/azure_monitor/config.go
+20
@@ -45,6 +45,7 @@ const (
45
46
type Config struct {
47
Vnode string `yaml:"vnode,omitempty" json:"vnode,omitempty"`
48
+ VirtualNodes *VirtualNodesConfig `yaml:"virtual_nodes,omitempty" json:"virtual_nodes,omitempty"`
49
UpdateEvery int `yaml:"update_every,omitempty" json:"update_every,omitempty"`
50
AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry,omitempty"`
51
SubscriptionIDs []string `yaml:"subscription_ids" json:"subscription_ids"`
@@ -57,6 +58,10 @@ type Config struct {
58
Auth cloudauth.AzureADAuthConfig `yaml:"auth" json:"auth"`
59
}
60
61
+type VirtualNodesConfig struct {
62
+ ByResourceTag string `yaml:"by_resource_tag,omitempty" json:"by_resource_tag,omitempty"`
63
+}
64
+
65
type DiscoveryConfig struct {
66
RefreshEvery int `yaml:"refresh_every,omitempty" json:"refresh_every"`
67
Mode string `yaml:"mode,omitempty" json:"mode"`
@@ -130,6 +135,14 @@ func (c *Config) applyDefaults() {
135
if c.Limits.MaxMetricsPerQuery <= 0 {
136
c.Limits.MaxMetricsPerQuery = defaultMaxMetricsQuery
137
}
138
+ if c.VirtualNodes != nil {
139
+ tagKey := stringsLowerTrim(c.VirtualNodes.ByResourceTag)
140
+ if tagKey == "" {
141
+ c.VirtualNodes = nil
142
+ } else {
143
+ c.VirtualNodes = &VirtualNodesConfig{ByResourceTag: tagKey}
144
+ }
145
+ }
146
}
147
148
func (c Config) validate() error {
@@ -411,3 +424,10 @@ func (c Config) subscriptionIDs() []string {
424
}
425
return out
426
}
427
+
428
+func (c Config) workloadResourceTagKey() string {
429
+ if c.VirtualNodes == nil {
430
+ return ""
431
+ }
432
+ return stringsLowerTrim(c.VirtualNodes.ByResourceTag)
433
+}
src/go/plugin/go.d/collector/azure_monitor/config_schema.json
+28
-4
@@ -513,6 +513,18 @@
513
"title": "Virtual Node",
514
"description": "Associates this data collection job with a Virtual Node.",
515
"type": "string"
516
+ },
517
+ "virtual_nodes": {
518
+ "title": "Virtual Nodes",
519
+ "description": "Creates Azure workload virtual nodes from Azure resource tags.",
520
+ "type": "object",
521
+ "properties": {
522
+ "by_resource_tag": {
523
+ "title": "By resource tag",
524
+ "description": "Optional Azure resource tag key. When set, resources with this tag are emitted under a virtual node whose hostname is the tag value. In custom query mode, project `tags` in the KQL to enable tag-based virtual nodes; missing or non-object tags fall back to the default host scope.",
525
+ "type": "string"
526
+ }
527
+ }
528
}
529
},
530
"required": [
@@ -578,8 +590,8 @@
590
},
591
"mode_query": {
592
"kql": {
581
- "ui:help": "Provide a custom Azure Resource Graph KQL query. The result must include resource `id`, `name`, `type`, `resourceGroup`, and `location`.",
582
- "ui:placeholder": "resources | where tags.env == 'prod' | project id, name, type, resourceGroup, location",
593
+ "ui:help": "Provide a custom Azure Resource Graph KQL query. The result must include resource `id`, `name`, `type`, `resourceGroup`, and `location`. If `virtual_nodes.by_resource_tag` is set, project `tags` too; rows without object-shaped `tags` use the default host scope.",
594
+ "ui:placeholder": "resources | where tags.env == 'prod' | project id, name, type, resourceGroup, location, tags",
595
"ui:widget": "textarea"
596
}
597
}
@@ -709,6 +721,12 @@
721
"ui:help": "Optional virtual node name used to group charts from this job under a specific Netdata virtual node.",
722
"ui:placeholder": "To use this option, first create a Virtual Node and then reference its name here."
723
},
724
+ "virtual_nodes": {
725
+ "by_resource_tag": {
726
+ "ui:help": "Optional Azure resource tag key used to create per-tag-value virtual nodes. Example: `workload` routes resources with `tags.workload=api` to a virtual node named `api`.",
727
+ "ui:placeholder": "workload"
728
+ }
729
+ },
730
"ui:flavour": "tabs",
731
"ui:options": {
732
"tabs": [
@@ -720,8 +738,7 @@
738
"update_every",
739
"autodetection_retry",
740
"query_offset",
723
- "timeout",
724
- "vnode"
741
+ "timeout"
742
]
743
},
744
{
@@ -747,6 +764,13 @@
764
"fields": [
765
"limits"
766
]
767
+ },
768
+ {
769
+ "title": "Virtual Node",
770
+ "fields": [
771
+ "vnode",
772
+ "virtual_nodes"
773
+ ]
774
}
775
]
776
}
src/go/plugin/go.d/collector/azure_monitor/discover.go
+68
-25
@@ -15,9 +15,11 @@ import (
15
)
16
17
type discoveryFetchResult struct {
18
- Resources []resourceInfo
19
- ByType map[string][]resourceInfo
20
- UnsupportedTypes []string
18
+ Resources []resourceInfo
19
+ ByType map[string][]resourceInfo
20
+ UnsupportedTypes []string
21
+ QueryTagsColumnMissing bool
22
+ QueryTagsWrongShape bool
23
}
24
25
type normalizedTagFilter struct {
@@ -49,24 +51,29 @@ func (c *Collector) refreshDiscovery(ctx context.Context, force bool) ([]resourc
51
c.Warningf("ignoring unsupported discovered resource types: %v", fetched.UnsupportedTypes)
52
}
53
52
- state := buildDiscoveryState(fetched.Resources, c.runtime, now, c.Discovery.RefreshEvery, c.discovery.FetchCounter+1)
54
+ state := buildDiscoveryState(fetched.Resources, c.runtime, now, c.Discovery.RefreshEvery, c.discovery.FetchCounter+1, fetched)
55
if !equalResourceSlices(state.Resources, c.discovery.Resources) {
56
c.Infof("discovered %d resources: %v", len(state.Resources), state.Resources)
57
}
58
59
c.discovery = state
60
+ c.warnDiscoveryScopeFallbacks(state, c.runtime)
61
return state.Resources, nil
62
}
63
61
-func buildDiscoveryState(resources []resourceInfo, runtime *collectorRuntime, now time.Time, refreshEvery int, fetchCounter uint64) discoveryState {
62
- filteredResources, byType := filterDiscoveryResourcesByTypes(resources, runtimeResourceTypes(runtime))
64
+func buildDiscoveryState(resources []resourceInfo, runtime *collectorRuntime, now time.Time, refreshEvery int, fetchCounter uint64, fetched discoveryFetchResult) discoveryState {
65
+ filteredResources, _ := filterDiscoveryResourcesByTypes(resources, runtimeResourceTypes(runtime))
66
+ scopeReport := applyWorkloadHostScopes(filteredResources, runtime)
67
return discoveryState{
64
- Resources: filteredResources,
65
- ByType: byType,
66
- ByProfile: filterDiscoveryResourcesByProfiles(filteredResources, runtime),
67
- FetchedAt: now,
68
- ExpiresAt: discoveryExpiresAt(now, refreshEvery),
69
- FetchCounter: fetchCounter,
68
+ Resources: filteredResources,
69
+ ByType: indexResourcesByType(filteredResources),
70
+ ByProfile: filterDiscoveryResourcesByProfiles(filteredResources, runtime),
71
+ FetchedAt: now,
72
+ ExpiresAt: discoveryExpiresAt(now, refreshEvery),
73
+ FetchCounter: fetchCounter,
74
+ QueryTagsColumnMissing: fetched.QueryTagsColumnMissing,
75
+ QueryTagsWrongShape: fetched.QueryTagsWrongShape,
76
+ UnsafeWorkloadValues: scopeReport.unsafeValues,
77
}
78
}
79
@@ -227,6 +234,7 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
234
result := make([]resourceInfo, 0, 256)
235
unsupported := make(map[string]struct{})
236
seenIDs := make(map[string]struct{})
237
+ var resultMissingTagsColumn, resultWrongTagsShape bool
238
239
var skipToken *string
240
for {
@@ -244,10 +252,16 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
252
}
253
254
for i, row := range rows {
247
- resource, err := parseStrictQueryDiscoveryRow(row)
255
+ resource, tagsShape, err := parseStrictQueryDiscoveryRow(row)
256
if err != nil {
257
return discoveryFetchResult{}, fmt.Errorf("query result row %d: %w", i, err)
258
}
259
+ switch tagsShape {
260
+ case queryTagsShapeAbsent:
261
+ resultMissingTagsColumn = true
262
+ case queryTagsShapeWrong:
263
+ resultWrongTagsShape = true
264
+ }
265
266
idKey := stringsLowerTrim(resource.ID)
267
if _, ok := seenIDs[idKey]; ok {
@@ -278,9 +292,11 @@ func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, t
292
slices.Sort(unsupportedTypes)
293
294
return discoveryFetchResult{
281
- Resources: result,
282
- ByType: indexResourcesByType(result),
283
- UnsupportedTypes: unsupportedTypes,
295
+ Resources: result,
296
+ ByType: indexResourcesByType(result),
297
+ UnsupportedTypes: unsupportedTypes,
298
+ QueryTagsColumnMissing: resultMissingTagsColumn,
299
+ QueryTagsWrongShape: resultWrongTagsShape,
300
}, nil
301
}
302
@@ -498,40 +514,49 @@ func normalizedFilterSet(values []string) map[string]struct{} {
514
return set
515
}
516
501
-func parseStrictQueryDiscoveryRow(row map[string]any) (resourceInfo, error) {
517
+type queryTagsShape int
518
+
519
+const (
520
+ queryTagsShapeAbsent queryTagsShape = iota
521
+ queryTagsShapePresentMap
522
+ queryTagsShapeWrong
523
+)
524
+
525
+func parseStrictQueryDiscoveryRow(row map[string]any) (resourceInfo, queryTagsShape, error) {
526
id, err := strictQueryStringColumn(row, "id")
527
if err != nil {
504
- return resourceInfo{}, err
528
+ return resourceInfo{}, queryTagsShapeAbsent, err
529
}
530
subscriptionID, ok := parseARMResourceID(id)
531
if !ok {
508
- return resourceInfo{}, fmt.Errorf("invalid ARM resource id %q", id)
532
+ return resourceInfo{}, queryTagsShapeAbsent, fmt.Errorf("invalid ARM resource id %q", id)
533
}
534
535
name, err := strictQueryStringColumn(row, "name")
536
if err != nil {
513
- return resourceInfo{}, err
537
+ return resourceInfo{}, queryTagsShapeAbsent, err
538
}
539
resourceType, err := strictQueryStringColumn(row, "type")
540
if err != nil {
517
- return resourceInfo{}, err
541
+ return resourceInfo{}, queryTagsShapeAbsent, err
542
}
543
if resourceType == "" {
520
- return resourceInfo{}, errors.New("column 'type' must not be empty")
544
+ return resourceInfo{}, queryTagsShapeAbsent, errors.New("column 'type' must not be empty")
545
}
546
547
resourceGroup, err := strictQueryStringColumn(row, "resourceGroup")
548
if err != nil {
525
- return resourceInfo{}, err
549
+ return resourceInfo{}, queryTagsShapeAbsent, err
550
}
551
location, err := strictQueryStringColumn(row, "location")
552
if err != nil {
529
- return resourceInfo{}, err
553
+ return resourceInfo{}, queryTagsShapeAbsent, err
554
}
555
region := stringsLowerTrim(location)
556
if region == "" {
557
region = "global"
558
}
559
+ tags, tagsShape := optionalQueryTagsColumn(row)
560
561
return resourceInfo{
562
SubscriptionID: subscriptionID,
@@ -541,7 +566,24 @@ func parseStrictQueryDiscoveryRow(row map[string]any) (resourceInfo, error) {
566
Type: resourceType,
567
ResourceGroup: resourceGroup,
568
Region: region,
544
- }, nil
569
+ Tags: tags,
570
+ }, tagsShape, nil
571
+}
572
+
573
+func optionalQueryTagsColumn(row map[string]any) ([]resourceTag, queryTagsShape) {
574
+ value, ok := row["tags"]
575
+ if !ok {
576
+ return nil, queryTagsShapeAbsent
577
+ }
578
+ if value == nil {
579
+ return nil, queryTagsShapePresentMap
580
+ }
581
+ switch value.(type) {
582
+ case map[string]any, map[string]string:
583
+ return normalizeResourceTags(value), queryTagsShapePresentMap
584
+ default:
585
+ return nil, queryTagsShapeWrong
586
+ }
587
}
588
589
func strictQueryStringColumn(row map[string]any, column string) (string, error) {
@@ -783,6 +825,7 @@ func equalResourceInfo(a, b resourceInfo) bool {
825
a.Type == b.Type &&
826
a.ResourceGroup == b.ResourceGroup &&
827
a.Region == b.Region &&
828
+ a.HostScope.ScopeKey == b.HostScope.ScopeKey &&
829
equalResourceTags(a.Tags, b.Tags)
830
}
831
src/go/plugin/go.d/collector/azure_monitor/init.go
+3
-2
@@ -117,7 +117,7 @@ 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.
120
- runtime, err := buildCollectorRuntimeFromConfig(selection.Names, selection.Entries, c.profileCatalog)
120
+ runtime, err := buildCollectorRuntimeFromConfig(selection.Names, selection.Entries, c.profileCatalog, c.Config.workloadResourceTagKey())
121
if err != nil {
122
return fmt.Errorf("build collector runtime: %w", err)
123
}
@@ -128,7 +128,8 @@ func (c *Collector) ensureBootstrapped(ctx context.Context) error {
128
129
c.runtime = runtime
130
c.observations = newObservationState(runtime.Instruments)
131
- c.discovery = buildDiscoveryState(fetched.Resources, runtime, now, c.Discovery.RefreshEvery, 1)
131
+ c.discovery = buildDiscoveryState(fetched.Resources, runtime, now, c.Discovery.RefreshEvery, 1, fetched)
132
+ c.warnDiscoveryScopeFallbacks(c.discovery, runtime)
133
134
return nil
135
}
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_api_management.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -688,6 +791,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
791
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
792
793
794
+### Workload virtual nodes are not created
795
+
796
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
797
+
798
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
799
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
800
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
801
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
802
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
803
+
804
+
805
+### More alerts appear after enabling workload virtual nodes
806
+
807
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
808
+
809
+
810
### Authentication errors in sovereign clouds
811
812
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_app_service.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -703,6 +806,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
806
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
807
808
809
+### Workload virtual nodes are not created
810
+
811
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
812
+
813
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
814
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
815
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
816
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
817
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
818
+
819
+
820
+### More alerts appear after enabling workload virtual nodes
821
+
822
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
823
+
824
+
825
### Authentication errors in sovereign clouds
826
827
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_application_gateway.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -694,6 +797,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
797
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
798
799
800
+### Workload virtual nodes are not created
801
+
802
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
803
+
804
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
805
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
806
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
807
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
808
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
809
+
810
+
811
+### More alerts appear after enabling workload virtual nodes
812
+
813
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
814
+
815
+
816
### Authentication errors in sovereign clouds
817
818
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_application_insights.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -703,6 +806,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
806
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
807
808
809
+### Workload virtual nodes are not created
810
+
811
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
812
+
813
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
814
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
815
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
816
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
817
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
818
+
819
+
820
+### More alerts appear after enabling workload virtual nodes
821
+
822
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
823
+
824
+
825
### Authentication errors in sovereign clouds
826
827
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_cache_for_redis.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -712,6 +815,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
815
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
816
817
818
+### Workload virtual nodes are not created
819
+
820
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
821
+
822
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
823
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
824
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
825
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
826
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
827
+
828
+
829
+### More alerts appear after enabling workload virtual nodes
830
+
831
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
832
+
833
+
834
### Authentication errors in sovereign clouds
835
836
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_cognitive_services.md
+124
-5
@@ -195,7 +195,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
196
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
197
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
198
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
198
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
199
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
200
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
201
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -203,7 +203,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
203
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
204
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
205
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
206
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
206
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
207
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
208
209
<a id="option-collection-query-offset"></a>
210
##### query_offset
@@ -238,7 +239,7 @@ Controls how the collector finds candidate Azure resources.
239
| Mode | Behavior |
240
|:-----|:---------|
241
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
241
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
242
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
243
244
245
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -257,6 +258,8 @@ The query **must** project these five columns:
258
| `resourceGroup` | Resource group name |
259
| `location` | Azure region |
260
261
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
262
+
263
:::info
264
265
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -270,7 +273,7 @@ Example:
273
```
274
resources
275
| where tags.env =~ "prod"
273
-| project id, name, type, resourceGroup, location
276
+| project id, name, type, resourceGroup, location, tags
277
```
278
279
@@ -344,6 +347,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
347
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
348
349
350
+<a id="option-virtual-node-vnode"></a>
351
+##### vnode
352
+
353
+This job-level virtual node is used for metrics written to the default host scope.
354
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
355
+
356
+
357
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
358
+##### virtual_nodes.by_resource_tag
359
+
360
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
361
+
362
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
363
+- Empty or missing tag values use the default job host scope.
364
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
365
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
366
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
367
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
368
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
369
+
370
+
371
372
</details>
373
@@ -415,6 +439,85 @@ jobs:
439
client_secret: "your-client-secret"
440
441
```
442
+###### Workload virtual nodes from resource tags
443
+
444
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
445
+
446
+<details open><summary>Config</summary>
447
+
448
+```yaml
449
+jobs:
450
+ - name: prod-workloads
451
+ subscription_ids:
452
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
453
+ virtual_nodes:
454
+ by_resource_tag: workload
455
+ discovery:
456
+ mode: filters
457
+ profiles:
458
+ mode: auto
459
+ auth:
460
+ mode: service_principal
461
+ mode_service_principal:
462
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
463
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
464
+ client_secret: "your-client-secret"
465
+
466
+```
467
+</details>
468
+
469
+###### Custom KQL with workload virtual nodes
470
+
471
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
472
+
473
+<details open><summary>Config</summary>
474
+
475
+```yaml
476
+jobs:
477
+ - name: prod-query-workloads
478
+ subscription_ids:
479
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
480
+ virtual_nodes:
481
+ by_resource_tag: workload
482
+ discovery:
483
+ mode: query
484
+ mode_query:
485
+ kql: |
486
+ resources
487
+ | where tags.env =~ "prod"
488
+ | project id, name, type, resourceGroup, location, tags
489
+ profiles:
490
+ mode: auto
491
+ auth:
492
+ mode: default
493
+
494
+```
495
+</details>
496
+
497
+###### Job virtual node plus workload virtual nodes
498
+
499
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
500
+
501
+<details open><summary>Config</summary>
502
+
503
+```yaml
504
+jobs:
505
+ - name: prod-with-fallback-node
506
+ vnode: azure-fallback-node
507
+ subscription_ids:
508
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
509
+ virtual_nodes:
510
+ by_resource_tag: workload
511
+ discovery:
512
+ mode: filters
513
+ profiles:
514
+ mode: auto
515
+ auth:
516
+ mode: managed_identity
517
+
518
+```
519
+</details>
520
+
521
###### Managed identity with exact profiles
522
523
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -494,7 +597,7 @@ jobs:
597
kql: |
598
resources
599
| where tags.env =~ "prod"
497
- | project id, name, type, resourceGroup, location
600
+ | project id, name, type, resourceGroup, location, tags
601
profiles:
602
mode: auto
603
auth:
@@ -762,6 +865,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
865
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
866
867
868
+### Workload virtual nodes are not created
869
+
870
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
871
+
872
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
873
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
874
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
875
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
876
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
877
+
878
+
879
+### More alerts appear after enabling workload virtual nodes
880
+
881
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
882
+
883
+
884
### Authentication errors in sovereign clouds
885
886
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_container_apps.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -700,6 +803,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
803
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
804
805
806
+### Workload virtual nodes are not created
807
+
808
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
809
+
810
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
811
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
812
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
813
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
814
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
815
+
816
+
817
+### More alerts appear after enabling workload virtual nodes
818
+
819
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
820
+
821
+
822
### Authentication errors in sovereign clouds
823
824
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_container_instances.md
+124
-5
@@ -186,7 +186,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
186
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
187
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
189
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
189
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
190
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
191
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
192
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -194,7 +194,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
194
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
195
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
196
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
197
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
197
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
199
200
<a id="option-collection-query-offset"></a>
201
##### query_offset
@@ -229,7 +230,7 @@ Controls how the collector finds candidate Azure resources.
230
| Mode | Behavior |
231
|:-----|:---------|
232
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
232
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
233
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
234
235
236
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -248,6 +249,8 @@ The query **must** project these five columns:
249
| `resourceGroup` | Resource group name |
250
| `location` | Azure region |
251
252
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
253
+
254
:::info
255
256
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -261,7 +264,7 @@ Example:
264
```
265
resources
266
| where tags.env =~ "prod"
264
-| project id, name, type, resourceGroup, location
267
+| project id, name, type, resourceGroup, location, tags
268
```
269
270
@@ -335,6 +338,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
338
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
339
340
341
+<a id="option-virtual-node-vnode"></a>
342
+##### vnode
343
+
344
+This job-level virtual node is used for metrics written to the default host scope.
345
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
346
+
347
+
348
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
349
+##### virtual_nodes.by_resource_tag
350
+
351
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
352
+
353
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
354
+- Empty or missing tag values use the default job host scope.
355
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
356
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
357
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
358
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
359
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
360
+
361
+
362
363
</details>
364
@@ -406,6 +430,85 @@ jobs:
430
client_secret: "your-client-secret"
431
432
```
433
+###### Workload virtual nodes from resource tags
434
+
435
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
436
+
437
+<details open><summary>Config</summary>
438
+
439
+```yaml
440
+jobs:
441
+ - name: prod-workloads
442
+ subscription_ids:
443
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
444
+ virtual_nodes:
445
+ by_resource_tag: workload
446
+ discovery:
447
+ mode: filters
448
+ profiles:
449
+ mode: auto
450
+ auth:
451
+ mode: service_principal
452
+ mode_service_principal:
453
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
454
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_secret: "your-client-secret"
456
+
457
+```
458
+</details>
459
+
460
+###### Custom KQL with workload virtual nodes
461
+
462
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
463
+
464
+<details open><summary>Config</summary>
465
+
466
+```yaml
467
+jobs:
468
+ - name: prod-query-workloads
469
+ subscription_ids:
470
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
471
+ virtual_nodes:
472
+ by_resource_tag: workload
473
+ discovery:
474
+ mode: query
475
+ mode_query:
476
+ kql: |
477
+ resources
478
+ | where tags.env =~ "prod"
479
+ | project id, name, type, resourceGroup, location, tags
480
+ profiles:
481
+ mode: auto
482
+ auth:
483
+ mode: default
484
+
485
+```
486
+</details>
487
+
488
+###### Job virtual node plus workload virtual nodes
489
+
490
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
491
+
492
+<details open><summary>Config</summary>
493
+
494
+```yaml
495
+jobs:
496
+ - name: prod-with-fallback-node
497
+ vnode: azure-fallback-node
498
+ subscription_ids:
499
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
500
+ virtual_nodes:
501
+ by_resource_tag: workload
502
+ discovery:
503
+ mode: filters
504
+ profiles:
505
+ mode: auto
506
+ auth:
507
+ mode: managed_identity
508
+
509
+```
510
+</details>
511
+
512
###### Managed identity with exact profiles
513
514
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -485,7 +588,7 @@ jobs:
588
kql: |
589
resources
590
| where tags.env =~ "prod"
488
- | project id, name, type, resourceGroup, location
591
+ | project id, name, type, resourceGroup, location, tags
592
profiles:
593
mode: auto
594
auth:
@@ -670,6 +773,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
773
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
774
775
776
+### Workload virtual nodes are not created
777
+
778
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
779
+
780
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
781
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
782
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
783
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
784
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
785
+
786
+
787
+### More alerts appear after enabling workload virtual nodes
788
+
789
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
790
+
791
+
792
### Authentication errors in sovereign clouds
793
794
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_container_registry.md
+124
-5
@@ -186,7 +186,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
186
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
187
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
189
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
189
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
190
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
191
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
192
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -194,7 +194,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
194
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
195
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
196
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
197
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
197
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
199
200
<a id="option-collection-query-offset"></a>
201
##### query_offset
@@ -229,7 +230,7 @@ Controls how the collector finds candidate Azure resources.
230
| Mode | Behavior |
231
|:-----|:---------|
232
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
232
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
233
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
234
235
236
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -248,6 +249,8 @@ The query **must** project these five columns:
249
| `resourceGroup` | Resource group name |
250
| `location` | Azure region |
251
252
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
253
+
254
:::info
255
256
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -261,7 +264,7 @@ Example:
264
```
265
resources
266
| where tags.env =~ "prod"
264
-| project id, name, type, resourceGroup, location
267
+| project id, name, type, resourceGroup, location, tags
268
```
269
270
@@ -335,6 +338,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
338
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
339
340
341
+<a id="option-virtual-node-vnode"></a>
342
+##### vnode
343
+
344
+This job-level virtual node is used for metrics written to the default host scope.
345
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
346
+
347
+
348
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
349
+##### virtual_nodes.by_resource_tag
350
+
351
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
352
+
353
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
354
+- Empty or missing tag values use the default job host scope.
355
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
356
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
357
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
358
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
359
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
360
+
361
+
362
363
</details>
364
@@ -406,6 +430,85 @@ jobs:
430
client_secret: "your-client-secret"
431
432
```
433
+###### Workload virtual nodes from resource tags
434
+
435
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
436
+
437
+<details open><summary>Config</summary>
438
+
439
+```yaml
440
+jobs:
441
+ - name: prod-workloads
442
+ subscription_ids:
443
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
444
+ virtual_nodes:
445
+ by_resource_tag: workload
446
+ discovery:
447
+ mode: filters
448
+ profiles:
449
+ mode: auto
450
+ auth:
451
+ mode: service_principal
452
+ mode_service_principal:
453
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
454
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_secret: "your-client-secret"
456
+
457
+```
458
+</details>
459
+
460
+###### Custom KQL with workload virtual nodes
461
+
462
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
463
+
464
+<details open><summary>Config</summary>
465
+
466
+```yaml
467
+jobs:
468
+ - name: prod-query-workloads
469
+ subscription_ids:
470
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
471
+ virtual_nodes:
472
+ by_resource_tag: workload
473
+ discovery:
474
+ mode: query
475
+ mode_query:
476
+ kql: |
477
+ resources
478
+ | where tags.env =~ "prod"
479
+ | project id, name, type, resourceGroup, location, tags
480
+ profiles:
481
+ mode: auto
482
+ auth:
483
+ mode: default
484
+
485
+```
486
+</details>
487
+
488
+###### Job virtual node plus workload virtual nodes
489
+
490
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
491
+
492
+<details open><summary>Config</summary>
493
+
494
+```yaml
495
+jobs:
496
+ - name: prod-with-fallback-node
497
+ vnode: azure-fallback-node
498
+ subscription_ids:
499
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
500
+ virtual_nodes:
501
+ by_resource_tag: workload
502
+ discovery:
503
+ mode: filters
504
+ profiles:
505
+ mode: auto
506
+ auth:
507
+ mode: managed_identity
508
+
509
+```
510
+</details>
511
+
512
###### Managed identity with exact profiles
513
514
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -485,7 +588,7 @@ jobs:
588
kql: |
589
resources
590
| where tags.env =~ "prod"
488
- | project id, name, type, resourceGroup, location
591
+ | project id, name, type, resourceGroup, location, tags
592
profiles:
593
mode: auto
594
auth:
@@ -670,6 +773,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
773
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
774
775
776
+### Workload virtual nodes are not created
777
+
778
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
779
+
780
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
781
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
782
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
783
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
784
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
785
+
786
+
787
+### More alerts appear after enabling workload virtual nodes
788
+
789
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
790
+
791
+
792
### Authentication errors in sovereign clouds
793
794
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_cosmos_db_account.md
+124
-5
@@ -189,7 +189,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
189
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
192
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
192
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
193
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
194
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
195
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -197,7 +197,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
197
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
198
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
199
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
200
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
202
203
<a id="option-collection-query-offset"></a>
204
##### query_offset
@@ -232,7 +233,7 @@ Controls how the collector finds candidate Azure resources.
233
| Mode | Behavior |
234
|:-----|:---------|
235
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
235
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
236
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
237
238
239
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -251,6 +252,8 @@ The query **must** project these five columns:
252
| `resourceGroup` | Resource group name |
253
| `location` | Azure region |
254
255
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
256
+
257
:::info
258
259
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -264,7 +267,7 @@ Example:
267
```
268
resources
269
| where tags.env =~ "prod"
267
-| project id, name, type, resourceGroup, location
270
+| project id, name, type, resourceGroup, location, tags
271
```
272
273
@@ -338,6 +341,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
341
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
342
343
344
+<a id="option-virtual-node-vnode"></a>
345
+##### vnode
346
+
347
+This job-level virtual node is used for metrics written to the default host scope.
348
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
349
+
350
+
351
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
352
+##### virtual_nodes.by_resource_tag
353
+
354
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
355
+
356
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
357
+- Empty or missing tag values use the default job host scope.
358
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
359
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
360
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
361
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
362
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
363
+
364
+
365
366
</details>
367
@@ -409,6 +433,85 @@ jobs:
433
client_secret: "your-client-secret"
434
435
```
436
+###### Workload virtual nodes from resource tags
437
+
438
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
439
+
440
+<details open><summary>Config</summary>
441
+
442
+```yaml
443
+jobs:
444
+ - name: prod-workloads
445
+ subscription_ids:
446
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
447
+ virtual_nodes:
448
+ by_resource_tag: workload
449
+ discovery:
450
+ mode: filters
451
+ profiles:
452
+ mode: auto
453
+ auth:
454
+ mode: service_principal
455
+ mode_service_principal:
456
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_secret: "your-client-secret"
459
+
460
+```
461
+</details>
462
+
463
+###### Custom KQL with workload virtual nodes
464
+
465
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
466
+
467
+<details open><summary>Config</summary>
468
+
469
+```yaml
470
+jobs:
471
+ - name: prod-query-workloads
472
+ subscription_ids:
473
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
474
+ virtual_nodes:
475
+ by_resource_tag: workload
476
+ discovery:
477
+ mode: query
478
+ mode_query:
479
+ kql: |
480
+ resources
481
+ | where tags.env =~ "prod"
482
+ | project id, name, type, resourceGroup, location, tags
483
+ profiles:
484
+ mode: auto
485
+ auth:
486
+ mode: default
487
+
488
+```
489
+</details>
490
+
491
+###### Job virtual node plus workload virtual nodes
492
+
493
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
494
+
495
+<details open><summary>Config</summary>
496
+
497
+```yaml
498
+jobs:
499
+ - name: prod-with-fallback-node
500
+ vnode: azure-fallback-node
501
+ subscription_ids:
502
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
503
+ virtual_nodes:
504
+ by_resource_tag: workload
505
+ discovery:
506
+ mode: filters
507
+ profiles:
508
+ mode: auto
509
+ auth:
510
+ mode: managed_identity
511
+
512
+```
513
+</details>
514
+
515
###### Managed identity with exact profiles
516
517
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -488,7 +591,7 @@ jobs:
591
kql: |
592
resources
593
| where tags.env =~ "prod"
491
- | project id, name, type, resourceGroup, location
594
+ | project id, name, type, resourceGroup, location, tags
595
profiles:
596
mode: auto
597
auth:
@@ -696,6 +799,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
799
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
800
801
802
+### Workload virtual nodes are not created
803
+
804
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
805
+
806
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
807
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
808
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
809
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
810
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
811
+
812
+
813
+### More alerts appear after enabling workload virtual nodes
814
+
815
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
816
+
817
+
818
### Authentication errors in sovereign clouds
819
820
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_data_explorer_cluster.md
+124
-5
@@ -193,7 +193,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
193
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
195
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
196
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
196
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
197
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
198
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
199
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -201,7 +201,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
201
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
202
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
203
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
204
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
205
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
206
207
<a id="option-collection-query-offset"></a>
208
##### query_offset
@@ -236,7 +237,7 @@ Controls how the collector finds candidate Azure resources.
237
| Mode | Behavior |
238
|:-----|:---------|
239
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
239
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
240
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
241
242
243
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -255,6 +256,8 @@ The query **must** project these five columns:
256
| `resourceGroup` | Resource group name |
257
| `location` | Azure region |
258
259
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
260
+
261
:::info
262
263
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -268,7 +271,7 @@ Example:
271
```
272
resources
273
| where tags.env =~ "prod"
271
-| project id, name, type, resourceGroup, location
274
+| project id, name, type, resourceGroup, location, tags
275
```
276
277
@@ -342,6 +345,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
345
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
346
347
348
+<a id="option-virtual-node-vnode"></a>
349
+##### vnode
350
+
351
+This job-level virtual node is used for metrics written to the default host scope.
352
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
353
+
354
+
355
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
356
+##### virtual_nodes.by_resource_tag
357
+
358
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
359
+
360
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
361
+- Empty or missing tag values use the default job host scope.
362
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
363
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
364
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
365
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
366
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
367
+
368
+
369
370
</details>
371
@@ -413,6 +437,85 @@ jobs:
437
client_secret: "your-client-secret"
438
439
```
440
+###### Workload virtual nodes from resource tags
441
+
442
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
443
+
444
+<details open><summary>Config</summary>
445
+
446
+```yaml
447
+jobs:
448
+ - name: prod-workloads
449
+ subscription_ids:
450
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
451
+ virtual_nodes:
452
+ by_resource_tag: workload
453
+ discovery:
454
+ mode: filters
455
+ profiles:
456
+ mode: auto
457
+ auth:
458
+ mode: service_principal
459
+ mode_service_principal:
460
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
462
+ client_secret: "your-client-secret"
463
+
464
+```
465
+</details>
466
+
467
+###### Custom KQL with workload virtual nodes
468
+
469
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
470
+
471
+<details open><summary>Config</summary>
472
+
473
+```yaml
474
+jobs:
475
+ - name: prod-query-workloads
476
+ subscription_ids:
477
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
478
+ virtual_nodes:
479
+ by_resource_tag: workload
480
+ discovery:
481
+ mode: query
482
+ mode_query:
483
+ kql: |
484
+ resources
485
+ | where tags.env =~ "prod"
486
+ | project id, name, type, resourceGroup, location, tags
487
+ profiles:
488
+ mode: auto
489
+ auth:
490
+ mode: default
491
+
492
+```
493
+</details>
494
+
495
+###### Job virtual node plus workload virtual nodes
496
+
497
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
498
+
499
+<details open><summary>Config</summary>
500
+
501
+```yaml
502
+jobs:
503
+ - name: prod-with-fallback-node
504
+ vnode: azure-fallback-node
505
+ subscription_ids:
506
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
507
+ virtual_nodes:
508
+ by_resource_tag: workload
509
+ discovery:
510
+ mode: filters
511
+ profiles:
512
+ mode: auto
513
+ auth:
514
+ mode: managed_identity
515
+
516
+```
517
+</details>
518
+
519
###### Managed identity with exact profiles
520
521
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -492,7 +595,7 @@ jobs:
595
kql: |
596
resources
597
| where tags.env =~ "prod"
495
- | project id, name, type, resourceGroup, location
598
+ | project id, name, type, resourceGroup, location, tags
599
profiles:
600
mode: auto
601
auth:
@@ -735,6 +838,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
838
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
839
840
841
+### Workload virtual nodes are not created
842
+
843
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
844
+
845
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
846
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
847
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
848
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
849
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
850
+
851
+
852
+### More alerts appear after enabling workload virtual nodes
853
+
854
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
855
+
856
+
857
### Authentication errors in sovereign clouds
858
859
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_data_factory.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -742,6 +845,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
845
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
846
847
848
+### Workload virtual nodes are not created
849
+
850
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
851
+
852
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
853
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
854
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
855
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
856
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
857
+
858
+
859
+### More alerts appear after enabling workload virtual nodes
860
+
861
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
862
+
863
+
864
### Authentication errors in sovereign clouds
865
866
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_event_grid_topic.md
+124
-5
@@ -187,7 +187,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
187
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
190
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
190
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
191
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
192
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
193
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -195,7 +195,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
196
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
197
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
198
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
200
201
<a id="option-collection-query-offset"></a>
202
##### query_offset
@@ -230,7 +231,7 @@ Controls how the collector finds candidate Azure resources.
231
| Mode | Behavior |
232
|:-----|:---------|
233
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
233
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
234
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
235
236
237
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -249,6 +250,8 @@ The query **must** project these five columns:
250
| `resourceGroup` | Resource group name |
251
| `location` | Azure region |
252
253
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
254
+
255
:::info
256
257
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -262,7 +265,7 @@ Example:
265
```
266
resources
267
| where tags.env =~ "prod"
265
-| project id, name, type, resourceGroup, location
268
+| project id, name, type, resourceGroup, location, tags
269
```
270
271
@@ -336,6 +339,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
339
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
340
341
342
+<a id="option-virtual-node-vnode"></a>
343
+##### vnode
344
+
345
+This job-level virtual node is used for metrics written to the default host scope.
346
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
347
+
348
+
349
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
350
+##### virtual_nodes.by_resource_tag
351
+
352
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
353
+
354
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
355
+- Empty or missing tag values use the default job host scope.
356
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
357
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
358
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
359
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
360
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
361
+
362
+
363
364
</details>
365
@@ -407,6 +431,85 @@ jobs:
431
client_secret: "your-client-secret"
432
433
```
434
+###### Workload virtual nodes from resource tags
435
+
436
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
437
+
438
+<details open><summary>Config</summary>
439
+
440
+```yaml
441
+jobs:
442
+ - name: prod-workloads
443
+ subscription_ids:
444
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
445
+ virtual_nodes:
446
+ by_resource_tag: workload
447
+ discovery:
448
+ mode: filters
449
+ profiles:
450
+ mode: auto
451
+ auth:
452
+ mode: service_principal
453
+ mode_service_principal:
454
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_secret: "your-client-secret"
457
+
458
+```
459
+</details>
460
+
461
+###### Custom KQL with workload virtual nodes
462
+
463
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
464
+
465
+<details open><summary>Config</summary>
466
+
467
+```yaml
468
+jobs:
469
+ - name: prod-query-workloads
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: query
476
+ mode_query:
477
+ kql: |
478
+ resources
479
+ | where tags.env =~ "prod"
480
+ | project id, name, type, resourceGroup, location, tags
481
+ profiles:
482
+ mode: auto
483
+ auth:
484
+ mode: default
485
+
486
+```
487
+</details>
488
+
489
+###### Job virtual node plus workload virtual nodes
490
+
491
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
492
+
493
+<details open><summary>Config</summary>
494
+
495
+```yaml
496
+jobs:
497
+ - name: prod-with-fallback-node
498
+ vnode: azure-fallback-node
499
+ subscription_ids:
500
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501
+ virtual_nodes:
502
+ by_resource_tag: workload
503
+ discovery:
504
+ mode: filters
505
+ profiles:
506
+ mode: auto
507
+ auth:
508
+ mode: managed_identity
509
+
510
+```
511
+</details>
512
+
513
###### Managed identity with exact profiles
514
515
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -486,7 +589,7 @@ jobs:
589
kql: |
590
resources
591
| where tags.env =~ "prod"
489
- | project id, name, type, resourceGroup, location
592
+ | project id, name, type, resourceGroup, location, tags
593
profiles:
594
mode: auto
595
auth:
@@ -676,6 +779,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
779
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
780
781
782
+### Workload virtual nodes are not created
783
+
784
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
785
+
786
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
787
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
788
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
789
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
790
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
791
+
792
+
793
+### More alerts appear after enabling workload virtual nodes
794
+
795
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
796
+
797
+
798
### Authentication errors in sovereign clouds
799
800
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_event_hubs_namespace.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -690,6 +793,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
793
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
794
795
796
+### Workload virtual nodes are not created
797
+
798
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
799
+
800
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
801
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
802
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
803
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
804
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
805
+
806
+
807
+### More alerts appear after enabling workload virtual nodes
808
+
809
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
810
+
811
+
812
### Authentication errors in sovereign clouds
813
814
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_expressroute_circuit.md
+124
-5
@@ -188,7 +188,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
188
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
191
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
191
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
192
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
193
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
194
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -196,7 +196,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
196
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
197
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
198
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
199
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
201
202
<a id="option-collection-query-offset"></a>
203
##### query_offset
@@ -231,7 +232,7 @@ Controls how the collector finds candidate Azure resources.
232
| Mode | Behavior |
233
|:-----|:---------|
234
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
234
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
235
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
236
237
238
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -250,6 +251,8 @@ The query **must** project these five columns:
251
| `resourceGroup` | Resource group name |
252
| `location` | Azure region |
253
254
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
255
+
256
:::info
257
258
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -263,7 +266,7 @@ Example:
266
```
267
resources
268
| where tags.env =~ "prod"
266
-| project id, name, type, resourceGroup, location
269
+| project id, name, type, resourceGroup, location, tags
270
```
271
272
@@ -337,6 +340,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
340
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
341
342
343
+<a id="option-virtual-node-vnode"></a>
344
+##### vnode
345
+
346
+This job-level virtual node is used for metrics written to the default host scope.
347
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
348
+
349
+
350
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
351
+##### virtual_nodes.by_resource_tag
352
+
353
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
354
+
355
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
356
+- Empty or missing tag values use the default job host scope.
357
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
358
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
359
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
360
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
361
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
362
+
363
+
364
365
</details>
366
@@ -408,6 +432,85 @@ jobs:
432
client_secret: "your-client-secret"
433
434
```
435
+###### Workload virtual nodes from resource tags
436
+
437
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
438
+
439
+<details open><summary>Config</summary>
440
+
441
+```yaml
442
+jobs:
443
+ - name: prod-workloads
444
+ subscription_ids:
445
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
446
+ virtual_nodes:
447
+ by_resource_tag: workload
448
+ discovery:
449
+ mode: filters
450
+ profiles:
451
+ mode: auto
452
+ auth:
453
+ mode: service_principal
454
+ mode_service_principal:
455
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_secret: "your-client-secret"
458
+
459
+```
460
+</details>
461
+
462
+###### Custom KQL with workload virtual nodes
463
+
464
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
465
+
466
+<details open><summary>Config</summary>
467
+
468
+```yaml
469
+jobs:
470
+ - name: prod-query-workloads
471
+ subscription_ids:
472
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
473
+ virtual_nodes:
474
+ by_resource_tag: workload
475
+ discovery:
476
+ mode: query
477
+ mode_query:
478
+ kql: |
479
+ resources
480
+ | where tags.env =~ "prod"
481
+ | project id, name, type, resourceGroup, location, tags
482
+ profiles:
483
+ mode: auto
484
+ auth:
485
+ mode: default
486
+
487
+```
488
+</details>
489
+
490
+###### Job virtual node plus workload virtual nodes
491
+
492
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
493
+
494
+<details open><summary>Config</summary>
495
+
496
+```yaml
497
+jobs:
498
+ - name: prod-with-fallback-node
499
+ vnode: azure-fallback-node
500
+ subscription_ids:
501
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
502
+ virtual_nodes:
503
+ by_resource_tag: workload
504
+ discovery:
505
+ mode: filters
506
+ profiles:
507
+ mode: auto
508
+ auth:
509
+ mode: managed_identity
510
+
511
+```
512
+</details>
513
+
514
###### Managed identity with exact profiles
515
516
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -487,7 +590,7 @@ jobs:
590
kql: |
591
resources
592
| where tags.env =~ "prod"
490
- | project id, name, type, resourceGroup, location
593
+ | project id, name, type, resourceGroup, location, tags
594
profiles:
595
mode: auto
596
auth:
@@ -678,6 +781,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
781
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
782
783
784
+### Workload virtual nodes are not created
785
+
786
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
787
+
788
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
789
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
790
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
791
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
792
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
793
+
794
+
795
+### More alerts appear after enabling workload virtual nodes
796
+
797
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
798
+
799
+
800
### Authentication errors in sovereign clouds
801
802
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_expressroute_gateway.md
+124
-5
@@ -188,7 +188,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
188
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
191
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
191
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
192
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
193
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
194
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -196,7 +196,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
196
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
197
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
198
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
199
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
201
202
<a id="option-collection-query-offset"></a>
203
##### query_offset
@@ -231,7 +232,7 @@ Controls how the collector finds candidate Azure resources.
232
| Mode | Behavior |
233
|:-----|:---------|
234
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
234
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
235
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
236
237
238
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -250,6 +251,8 @@ The query **must** project these five columns:
251
| `resourceGroup` | Resource group name |
252
| `location` | Azure region |
253
254
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
255
+
256
:::info
257
258
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -263,7 +266,7 @@ Example:
266
```
267
resources
268
| where tags.env =~ "prod"
266
-| project id, name, type, resourceGroup, location
269
+| project id, name, type, resourceGroup, location, tags
270
```
271
272
@@ -337,6 +340,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
340
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
341
342
343
+<a id="option-virtual-node-vnode"></a>
344
+##### vnode
345
+
346
+This job-level virtual node is used for metrics written to the default host scope.
347
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
348
+
349
+
350
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
351
+##### virtual_nodes.by_resource_tag
352
+
353
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
354
+
355
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
356
+- Empty or missing tag values use the default job host scope.
357
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
358
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
359
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
360
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
361
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
362
+
363
+
364
365
</details>
366
@@ -408,6 +432,85 @@ jobs:
432
client_secret: "your-client-secret"
433
434
```
435
+###### Workload virtual nodes from resource tags
436
+
437
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
438
+
439
+<details open><summary>Config</summary>
440
+
441
+```yaml
442
+jobs:
443
+ - name: prod-workloads
444
+ subscription_ids:
445
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
446
+ virtual_nodes:
447
+ by_resource_tag: workload
448
+ discovery:
449
+ mode: filters
450
+ profiles:
451
+ mode: auto
452
+ auth:
453
+ mode: service_principal
454
+ mode_service_principal:
455
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_secret: "your-client-secret"
458
+
459
+```
460
+</details>
461
+
462
+###### Custom KQL with workload virtual nodes
463
+
464
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
465
+
466
+<details open><summary>Config</summary>
467
+
468
+```yaml
469
+jobs:
470
+ - name: prod-query-workloads
471
+ subscription_ids:
472
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
473
+ virtual_nodes:
474
+ by_resource_tag: workload
475
+ discovery:
476
+ mode: query
477
+ mode_query:
478
+ kql: |
479
+ resources
480
+ | where tags.env =~ "prod"
481
+ | project id, name, type, resourceGroup, location, tags
482
+ profiles:
483
+ mode: auto
484
+ auth:
485
+ mode: default
486
+
487
+```
488
+</details>
489
+
490
+###### Job virtual node plus workload virtual nodes
491
+
492
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
493
+
494
+<details open><summary>Config</summary>
495
+
496
+```yaml
497
+jobs:
498
+ - name: prod-with-fallback-node
499
+ vnode: azure-fallback-node
500
+ subscription_ids:
501
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
502
+ virtual_nodes:
503
+ by_resource_tag: workload
504
+ discovery:
505
+ mode: filters
506
+ profiles:
507
+ mode: auto
508
+ auth:
509
+ mode: managed_identity
510
+
511
+```
512
+</details>
513
+
514
###### Managed identity with exact profiles
515
516
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -487,7 +590,7 @@ jobs:
590
kql: |
591
resources
592
| where tags.env =~ "prod"
490
- | project id, name, type, resourceGroup, location
593
+ | project id, name, type, resourceGroup, location, tags
594
profiles:
595
mode: auto
596
auth:
@@ -680,6 +783,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
783
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
784
785
786
+### Workload virtual nodes are not created
787
+
788
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
789
+
790
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
791
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
792
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
793
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
794
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
795
+
796
+
797
+### More alerts appear after enabling workload virtual nodes
798
+
799
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
800
+
801
+
802
### Authentication errors in sovereign clouds
803
804
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_firewall.md
+124
-5
@@ -189,7 +189,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
189
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
192
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
192
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
193
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
194
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
195
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -197,7 +197,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
197
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
198
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
199
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
200
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
202
203
<a id="option-collection-query-offset"></a>
204
##### query_offset
@@ -232,7 +233,7 @@ Controls how the collector finds candidate Azure resources.
233
| Mode | Behavior |
234
|:-----|:---------|
235
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
235
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
236
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
237
238
239
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -251,6 +252,8 @@ The query **must** project these five columns:
252
| `resourceGroup` | Resource group name |
253
| `location` | Azure region |
254
255
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
256
+
257
:::info
258
259
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -264,7 +267,7 @@ Example:
267
```
268
resources
269
| where tags.env =~ "prod"
267
-| project id, name, type, resourceGroup, location
270
+| project id, name, type, resourceGroup, location, tags
271
```
272
273
@@ -338,6 +341,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
341
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
342
343
344
+<a id="option-virtual-node-vnode"></a>
345
+##### vnode
346
+
347
+This job-level virtual node is used for metrics written to the default host scope.
348
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
349
+
350
+
351
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
352
+##### virtual_nodes.by_resource_tag
353
+
354
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
355
+
356
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
357
+- Empty or missing tag values use the default job host scope.
358
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
359
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
360
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
361
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
362
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
363
+
364
+
365
366
</details>
367
@@ -409,6 +433,85 @@ jobs:
433
client_secret: "your-client-secret"
434
435
```
436
+###### Workload virtual nodes from resource tags
437
+
438
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
439
+
440
+<details open><summary>Config</summary>
441
+
442
+```yaml
443
+jobs:
444
+ - name: prod-workloads
445
+ subscription_ids:
446
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
447
+ virtual_nodes:
448
+ by_resource_tag: workload
449
+ discovery:
450
+ mode: filters
451
+ profiles:
452
+ mode: auto
453
+ auth:
454
+ mode: service_principal
455
+ mode_service_principal:
456
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_secret: "your-client-secret"
459
+
460
+```
461
+</details>
462
+
463
+###### Custom KQL with workload virtual nodes
464
+
465
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
466
+
467
+<details open><summary>Config</summary>
468
+
469
+```yaml
470
+jobs:
471
+ - name: prod-query-workloads
472
+ subscription_ids:
473
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
474
+ virtual_nodes:
475
+ by_resource_tag: workload
476
+ discovery:
477
+ mode: query
478
+ mode_query:
479
+ kql: |
480
+ resources
481
+ | where tags.env =~ "prod"
482
+ | project id, name, type, resourceGroup, location, tags
483
+ profiles:
484
+ mode: auto
485
+ auth:
486
+ mode: default
487
+
488
+```
489
+</details>
490
+
491
+###### Job virtual node plus workload virtual nodes
492
+
493
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
494
+
495
+<details open><summary>Config</summary>
496
+
497
+```yaml
498
+jobs:
499
+ - name: prod-with-fallback-node
500
+ vnode: azure-fallback-node
501
+ subscription_ids:
502
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
503
+ virtual_nodes:
504
+ by_resource_tag: workload
505
+ discovery:
506
+ mode: filters
507
+ profiles:
508
+ mode: auto
509
+ auth:
510
+ mode: managed_identity
511
+
512
+```
513
+</details>
514
+
515
###### Managed identity with exact profiles
516
517
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -488,7 +591,7 @@ jobs:
591
kql: |
592
resources
593
| where tags.env =~ "prod"
491
- | project id, name, type, resourceGroup, location
594
+ | project id, name, type, resourceGroup, location, tags
595
profiles:
596
mode: auto
597
auth:
@@ -676,6 +779,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
779
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
780
781
782
+### Workload virtual nodes are not created
783
+
784
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
785
+
786
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
787
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
788
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
789
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
790
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
791
+
792
+
793
+### More alerts appear after enabling workload virtual nodes
794
+
795
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
796
+
797
+
798
### Authentication errors in sovereign clouds
799
800
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_front_door.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -686,6 +789,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
789
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
790
791
792
+### Workload virtual nodes are not created
793
+
794
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
795
+
796
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
797
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
798
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
799
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
800
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
801
+
802
+
803
+### More alerts appear after enabling workload virtual nodes
804
+
805
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
806
+
807
+
808
### Authentication errors in sovereign clouds
809
810
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_functions.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -689,6 +792,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
792
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
793
794
795
+### Workload virtual nodes are not created
796
+
797
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
798
+
799
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
800
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
801
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
802
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
803
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
804
+
805
+
806
+### More alerts appear after enabling workload virtual nodes
807
+
808
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
809
+
810
+
811
### Authentication errors in sovereign clouds
812
813
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_iot_hub.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -725,6 +828,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
828
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
829
830
831
+### Workload virtual nodes are not created
832
+
833
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
834
+
835
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
836
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
837
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
838
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
839
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
840
+
841
+
842
+### More alerts appear after enabling workload virtual nodes
843
+
844
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
845
+
846
+
847
### Authentication errors in sovereign clouds
848
849
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_key_vault.md
+124
-5
@@ -186,7 +186,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
186
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
187
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
189
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
189
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
190
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
191
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
192
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -194,7 +194,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
194
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
195
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
196
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
197
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
197
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
199
200
<a id="option-collection-query-offset"></a>
201
##### query_offset
@@ -229,7 +230,7 @@ Controls how the collector finds candidate Azure resources.
230
| Mode | Behavior |
231
|:-----|:---------|
232
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
232
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
233
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
234
235
236
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -248,6 +249,8 @@ The query **must** project these five columns:
249
| `resourceGroup` | Resource group name |
250
| `location` | Azure region |
251
252
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
253
+
254
:::info
255
256
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -261,7 +264,7 @@ Example:
264
```
265
resources
266
| where tags.env =~ "prod"
264
-| project id, name, type, resourceGroup, location
267
+| project id, name, type, resourceGroup, location, tags
268
```
269
270
@@ -335,6 +338,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
338
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
339
340
341
+<a id="option-virtual-node-vnode"></a>
342
+##### vnode
343
+
344
+This job-level virtual node is used for metrics written to the default host scope.
345
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
346
+
347
+
348
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
349
+##### virtual_nodes.by_resource_tag
350
+
351
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
352
+
353
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
354
+- Empty or missing tag values use the default job host scope.
355
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
356
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
357
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
358
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
359
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
360
+
361
+
362
363
</details>
364
@@ -406,6 +430,85 @@ jobs:
430
client_secret: "your-client-secret"
431
432
```
433
+###### Workload virtual nodes from resource tags
434
+
435
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
436
+
437
+<details open><summary>Config</summary>
438
+
439
+```yaml
440
+jobs:
441
+ - name: prod-workloads
442
+ subscription_ids:
443
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
444
+ virtual_nodes:
445
+ by_resource_tag: workload
446
+ discovery:
447
+ mode: filters
448
+ profiles:
449
+ mode: auto
450
+ auth:
451
+ mode: service_principal
452
+ mode_service_principal:
453
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
454
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_secret: "your-client-secret"
456
+
457
+```
458
+</details>
459
+
460
+###### Custom KQL with workload virtual nodes
461
+
462
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
463
+
464
+<details open><summary>Config</summary>
465
+
466
+```yaml
467
+jobs:
468
+ - name: prod-query-workloads
469
+ subscription_ids:
470
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
471
+ virtual_nodes:
472
+ by_resource_tag: workload
473
+ discovery:
474
+ mode: query
475
+ mode_query:
476
+ kql: |
477
+ resources
478
+ | where tags.env =~ "prod"
479
+ | project id, name, type, resourceGroup, location, tags
480
+ profiles:
481
+ mode: auto
482
+ auth:
483
+ mode: default
484
+
485
+```
486
+</details>
487
+
488
+###### Job virtual node plus workload virtual nodes
489
+
490
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
491
+
492
+<details open><summary>Config</summary>
493
+
494
+```yaml
495
+jobs:
496
+ - name: prod-with-fallback-node
497
+ vnode: azure-fallback-node
498
+ subscription_ids:
499
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
500
+ virtual_nodes:
501
+ by_resource_tag: workload
502
+ discovery:
503
+ mode: filters
504
+ profiles:
505
+ mode: auto
506
+ auth:
507
+ mode: managed_identity
508
+
509
+```
510
+</details>
511
+
512
###### Managed identity with exact profiles
513
514
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -485,7 +588,7 @@ jobs:
588
kql: |
589
resources
590
| where tags.env =~ "prod"
488
- | project id, name, type, resourceGroup, location
591
+ | project id, name, type, resourceGroup, location, tags
592
profiles:
593
mode: auto
594
auth:
@@ -670,6 +773,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
773
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
774
775
776
+### Workload virtual nodes are not created
777
+
778
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
779
+
780
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
781
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
782
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
783
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
784
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
785
+
786
+
787
+### More alerts appear after enabling workload virtual nodes
788
+
789
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
790
+
791
+
792
### Authentication errors in sovereign clouds
793
794
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_kubernetes_service_cluster.md
+124
-5
@@ -187,7 +187,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
187
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
190
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
190
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
191
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
192
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
193
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -195,7 +195,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
196
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
197
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
198
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
200
201
<a id="option-collection-query-offset"></a>
202
##### query_offset
@@ -230,7 +231,7 @@ Controls how the collector finds candidate Azure resources.
231
| Mode | Behavior |
232
|:-----|:---------|
233
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
233
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
234
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
235
236
237
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -249,6 +250,8 @@ The query **must** project these five columns:
250
| `resourceGroup` | Resource group name |
251
| `location` | Azure region |
252
253
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
254
+
255
:::info
256
257
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -262,7 +265,7 @@ Example:
265
```
266
resources
267
| where tags.env =~ "prod"
265
-| project id, name, type, resourceGroup, location
268
+| project id, name, type, resourceGroup, location, tags
269
```
270
271
@@ -336,6 +339,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
339
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
340
341
342
+<a id="option-virtual-node-vnode"></a>
343
+##### vnode
344
+
345
+This job-level virtual node is used for metrics written to the default host scope.
346
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
347
+
348
+
349
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
350
+##### virtual_nodes.by_resource_tag
351
+
352
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
353
+
354
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
355
+- Empty or missing tag values use the default job host scope.
356
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
357
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
358
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
359
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
360
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
361
+
362
+
363
364
</details>
365
@@ -407,6 +431,85 @@ jobs:
431
client_secret: "your-client-secret"
432
433
```
434
+###### Workload virtual nodes from resource tags
435
+
436
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
437
+
438
+<details open><summary>Config</summary>
439
+
440
+```yaml
441
+jobs:
442
+ - name: prod-workloads
443
+ subscription_ids:
444
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
445
+ virtual_nodes:
446
+ by_resource_tag: workload
447
+ discovery:
448
+ mode: filters
449
+ profiles:
450
+ mode: auto
451
+ auth:
452
+ mode: service_principal
453
+ mode_service_principal:
454
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_secret: "your-client-secret"
457
+
458
+```
459
+</details>
460
+
461
+###### Custom KQL with workload virtual nodes
462
+
463
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
464
+
465
+<details open><summary>Config</summary>
466
+
467
+```yaml
468
+jobs:
469
+ - name: prod-query-workloads
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: query
476
+ mode_query:
477
+ kql: |
478
+ resources
479
+ | where tags.env =~ "prod"
480
+ | project id, name, type, resourceGroup, location, tags
481
+ profiles:
482
+ mode: auto
483
+ auth:
484
+ mode: default
485
+
486
+```
487
+</details>
488
+
489
+###### Job virtual node plus workload virtual nodes
490
+
491
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
492
+
493
+<details open><summary>Config</summary>
494
+
495
+```yaml
496
+jobs:
497
+ - name: prod-with-fallback-node
498
+ vnode: azure-fallback-node
499
+ subscription_ids:
500
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501
+ virtual_nodes:
502
+ by_resource_tag: workload
503
+ discovery:
504
+ mode: filters
505
+ profiles:
506
+ mode: auto
507
+ auth:
508
+ mode: managed_identity
509
+
510
+```
511
+</details>
512
+
513
###### Managed identity with exact profiles
514
515
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -486,7 +589,7 @@ jobs:
589
kql: |
590
resources
591
| where tags.env =~ "prod"
489
- | project id, name, type, resourceGroup, location
592
+ | project id, name, type, resourceGroup, location, tags
593
profiles:
594
mode: auto
595
auth:
@@ -699,6 +802,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
802
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
803
804
805
+### Workload virtual nodes are not created
806
+
807
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
808
+
809
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
810
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
811
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
812
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
813
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
814
+
815
+
816
+### More alerts appear after enabling workload virtual nodes
817
+
818
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
819
+
820
+
821
### Authentication errors in sovereign clouds
822
823
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_load_balancer.md
+124
-5
@@ -187,7 +187,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
187
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
190
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
190
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
191
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
192
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
193
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -195,7 +195,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
196
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
197
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
198
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
200
201
<a id="option-collection-query-offset"></a>
202
##### query_offset
@@ -230,7 +231,7 @@ Controls how the collector finds candidate Azure resources.
231
| Mode | Behavior |
232
|:-----|:---------|
233
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
233
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
234
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
235
236
237
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -249,6 +250,8 @@ The query **must** project these five columns:
250
| `resourceGroup` | Resource group name |
251
| `location` | Azure region |
252
253
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
254
+
255
:::info
256
257
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -262,7 +265,7 @@ Example:
265
```
266
resources
267
| where tags.env =~ "prod"
265
-| project id, name, type, resourceGroup, location
268
+| project id, name, type, resourceGroup, location, tags
269
```
270
271
@@ -336,6 +339,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
339
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
340
341
342
+<a id="option-virtual-node-vnode"></a>
343
+##### vnode
344
+
345
+This job-level virtual node is used for metrics written to the default host scope.
346
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
347
+
348
+
349
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
350
+##### virtual_nodes.by_resource_tag
351
+
352
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
353
+
354
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
355
+- Empty or missing tag values use the default job host scope.
356
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
357
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
358
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
359
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
360
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
361
+
362
+
363
364
</details>
365
@@ -407,6 +431,85 @@ jobs:
431
client_secret: "your-client-secret"
432
433
```
434
+###### Workload virtual nodes from resource tags
435
+
436
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
437
+
438
+<details open><summary>Config</summary>
439
+
440
+```yaml
441
+jobs:
442
+ - name: prod-workloads
443
+ subscription_ids:
444
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
445
+ virtual_nodes:
446
+ by_resource_tag: workload
447
+ discovery:
448
+ mode: filters
449
+ profiles:
450
+ mode: auto
451
+ auth:
452
+ mode: service_principal
453
+ mode_service_principal:
454
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_secret: "your-client-secret"
457
+
458
+```
459
+</details>
460
+
461
+###### Custom KQL with workload virtual nodes
462
+
463
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
464
+
465
+<details open><summary>Config</summary>
466
+
467
+```yaml
468
+jobs:
469
+ - name: prod-query-workloads
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: query
476
+ mode_query:
477
+ kql: |
478
+ resources
479
+ | where tags.env =~ "prod"
480
+ | project id, name, type, resourceGroup, location, tags
481
+ profiles:
482
+ mode: auto
483
+ auth:
484
+ mode: default
485
+
486
+```
487
+</details>
488
+
489
+###### Job virtual node plus workload virtual nodes
490
+
491
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
492
+
493
+<details open><summary>Config</summary>
494
+
495
+```yaml
496
+jobs:
497
+ - name: prod-with-fallback-node
498
+ vnode: azure-fallback-node
499
+ subscription_ids:
500
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501
+ virtual_nodes:
502
+ by_resource_tag: workload
503
+ discovery:
504
+ mode: filters
505
+ profiles:
506
+ mode: auto
507
+ auth:
508
+ mode: managed_identity
509
+
510
+```
511
+</details>
512
+
513
###### Managed identity with exact profiles
514
515
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -486,7 +589,7 @@ jobs:
589
kql: |
590
resources
591
| where tags.env =~ "prod"
489
- | project id, name, type, resourceGroup, location
592
+ | project id, name, type, resourceGroup, location, tags
593
profiles:
594
mode: auto
595
auth:
@@ -676,6 +779,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
779
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
780
781
782
+### Workload virtual nodes are not created
783
+
784
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
785
+
786
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
787
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
788
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
789
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
790
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
791
+
792
+
793
+### More alerts appear after enabling workload virtual nodes
794
+
795
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
796
+
797
+
798
### Authentication errors in sovereign clouds
799
800
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_log_analytics_workspace.md
+124
-5
@@ -187,7 +187,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
187
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
190
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
190
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
191
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
192
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
193
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -195,7 +195,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
196
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
197
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
198
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
200
201
<a id="option-collection-query-offset"></a>
202
##### query_offset
@@ -230,7 +231,7 @@ Controls how the collector finds candidate Azure resources.
231
| Mode | Behavior |
232
|:-----|:---------|
233
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
233
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
234
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
235
236
237
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -249,6 +250,8 @@ The query **must** project these five columns:
250
| `resourceGroup` | Resource group name |
251
| `location` | Azure region |
252
253
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
254
+
255
:::info
256
257
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -262,7 +265,7 @@ Example:
265
```
266
resources
267
| where tags.env =~ "prod"
265
-| project id, name, type, resourceGroup, location
268
+| project id, name, type, resourceGroup, location, tags
269
```
270
271
@@ -336,6 +339,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
339
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
340
341
342
+<a id="option-virtual-node-vnode"></a>
343
+##### vnode
344
+
345
+This job-level virtual node is used for metrics written to the default host scope.
346
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
347
+
348
+
349
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
350
+##### virtual_nodes.by_resource_tag
351
+
352
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
353
+
354
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
355
+- Empty or missing tag values use the default job host scope.
356
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
357
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
358
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
359
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
360
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
361
+
362
+
363
364
</details>
365
@@ -407,6 +431,85 @@ jobs:
431
client_secret: "your-client-secret"
432
433
```
434
+###### Workload virtual nodes from resource tags
435
+
436
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
437
+
438
+<details open><summary>Config</summary>
439
+
440
+```yaml
441
+jobs:
442
+ - name: prod-workloads
443
+ subscription_ids:
444
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
445
+ virtual_nodes:
446
+ by_resource_tag: workload
447
+ discovery:
448
+ mode: filters
449
+ profiles:
450
+ mode: auto
451
+ auth:
452
+ mode: service_principal
453
+ mode_service_principal:
454
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_secret: "your-client-secret"
457
+
458
+```
459
+</details>
460
+
461
+###### Custom KQL with workload virtual nodes
462
+
463
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
464
+
465
+<details open><summary>Config</summary>
466
+
467
+```yaml
468
+jobs:
469
+ - name: prod-query-workloads
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: query
476
+ mode_query:
477
+ kql: |
478
+ resources
479
+ | where tags.env =~ "prod"
480
+ | project id, name, type, resourceGroup, location, tags
481
+ profiles:
482
+ mode: auto
483
+ auth:
484
+ mode: default
485
+
486
+```
487
+</details>
488
+
489
+###### Job virtual node plus workload virtual nodes
490
+
491
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
492
+
493
+<details open><summary>Config</summary>
494
+
495
+```yaml
496
+jobs:
497
+ - name: prod-with-fallback-node
498
+ vnode: azure-fallback-node
499
+ subscription_ids:
500
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501
+ virtual_nodes:
502
+ by_resource_tag: workload
503
+ discovery:
504
+ mode: filters
505
+ profiles:
506
+ mode: auto
507
+ auth:
508
+ mode: managed_identity
509
+
510
+```
511
+</details>
512
+
513
###### Managed identity with exact profiles
514
515
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -486,7 +589,7 @@ jobs:
589
kql: |
590
resources
591
| where tags.env =~ "prod"
489
- | project id, name, type, resourceGroup, location
592
+ | project id, name, type, resourceGroup, location, tags
593
profiles:
594
mode: auto
595
auth:
@@ -715,6 +818,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
818
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
819
820
821
+### Workload virtual nodes are not created
822
+
823
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
824
+
825
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
826
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
827
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
828
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
829
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
830
+
831
+
832
+### More alerts appear after enabling workload virtual nodes
833
+
834
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
835
+
836
+
837
### Authentication errors in sovereign clouds
838
839
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_logic_apps_workflow.md
+124
-5
@@ -189,7 +189,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
189
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
192
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
192
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
193
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
194
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
195
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -197,7 +197,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
197
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
198
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
199
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
200
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
202
203
<a id="option-collection-query-offset"></a>
204
##### query_offset
@@ -232,7 +233,7 @@ Controls how the collector finds candidate Azure resources.
233
| Mode | Behavior |
234
|:-----|:---------|
235
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
235
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
236
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
237
238
239
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -251,6 +252,8 @@ The query **must** project these five columns:
252
| `resourceGroup` | Resource group name |
253
| `location` | Azure region |
254
255
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
256
+
257
:::info
258
259
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -264,7 +267,7 @@ Example:
267
```
268
resources
269
| where tags.env =~ "prod"
267
-| project id, name, type, resourceGroup, location
270
+| project id, name, type, resourceGroup, location, tags
271
```
272
273
@@ -338,6 +341,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
341
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
342
343
344
+<a id="option-virtual-node-vnode"></a>
345
+##### vnode
346
+
347
+This job-level virtual node is used for metrics written to the default host scope.
348
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
349
+
350
+
351
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
352
+##### virtual_nodes.by_resource_tag
353
+
354
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
355
+
356
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
357
+- Empty or missing tag values use the default job host scope.
358
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
359
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
360
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
361
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
362
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
363
+
364
+
365
366
</details>
367
@@ -409,6 +433,85 @@ jobs:
433
client_secret: "your-client-secret"
434
435
```
436
+###### Workload virtual nodes from resource tags
437
+
438
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
439
+
440
+<details open><summary>Config</summary>
441
+
442
+```yaml
443
+jobs:
444
+ - name: prod-workloads
445
+ subscription_ids:
446
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
447
+ virtual_nodes:
448
+ by_resource_tag: workload
449
+ discovery:
450
+ mode: filters
451
+ profiles:
452
+ mode: auto
453
+ auth:
454
+ mode: service_principal
455
+ mode_service_principal:
456
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_secret: "your-client-secret"
459
+
460
+```
461
+</details>
462
+
463
+###### Custom KQL with workload virtual nodes
464
+
465
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
466
+
467
+<details open><summary>Config</summary>
468
+
469
+```yaml
470
+jobs:
471
+ - name: prod-query-workloads
472
+ subscription_ids:
473
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
474
+ virtual_nodes:
475
+ by_resource_tag: workload
476
+ discovery:
477
+ mode: query
478
+ mode_query:
479
+ kql: |
480
+ resources
481
+ | where tags.env =~ "prod"
482
+ | project id, name, type, resourceGroup, location, tags
483
+ profiles:
484
+ mode: auto
485
+ auth:
486
+ mode: default
487
+
488
+```
489
+</details>
490
+
491
+###### Job virtual node plus workload virtual nodes
492
+
493
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
494
+
495
+<details open><summary>Config</summary>
496
+
497
+```yaml
498
+jobs:
499
+ - name: prod-with-fallback-node
500
+ vnode: azure-fallback-node
501
+ subscription_ids:
502
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
503
+ virtual_nodes:
504
+ by_resource_tag: workload
505
+ discovery:
506
+ mode: filters
507
+ profiles:
508
+ mode: auto
509
+ auth:
510
+ mode: managed_identity
511
+
512
+```
513
+</details>
514
+
515
###### Managed identity with exact profiles
516
517
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -488,7 +591,7 @@ jobs:
591
kql: |
592
resources
593
| where tags.env =~ "prod"
491
- | project id, name, type, resourceGroup, location
594
+ | project id, name, type, resourceGroup, location, tags
595
profiles:
596
mode: auto
597
auth:
@@ -691,6 +794,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
794
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
795
796
797
+### Workload virtual nodes are not created
798
+
799
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
800
+
801
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
802
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
803
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
804
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
805
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
806
+
807
+
808
+### More alerts appear after enabling workload virtual nodes
809
+
810
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
811
+
812
+
813
### Authentication errors in sovereign clouds
814
815
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_machine_learning_workspace.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -714,6 +817,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
817
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
818
819
820
+### Workload virtual nodes are not created
821
+
822
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
823
+
824
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
825
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
826
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
827
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
828
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
829
+
830
+
831
+### More alerts appear after enabling workload virtual nodes
832
+
833
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
834
+
835
+
836
### Authentication errors in sovereign clouds
837
838
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_monitor.md
+124
-5
@@ -183,7 +183,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
183
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
184
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
185
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
186
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
186
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
187
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
188
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
189
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -191,7 +191,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
192
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
193
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
194
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
194
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
195
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
196
197
<a id="option-collection-query-offset"></a>
198
##### query_offset
@@ -226,7 +227,7 @@ Controls how the collector finds candidate Azure resources.
227
| Mode | Behavior |
228
|:-----|:---------|
229
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
229
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
230
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
231
232
233
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -245,6 +246,8 @@ The query **must** project these five columns:
246
| `resourceGroup` | Resource group name |
247
| `location` | Azure region |
248
249
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
250
+
251
:::info
252
253
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -258,7 +261,7 @@ Example:
261
```
262
resources
263
| where tags.env =~ "prod"
261
-| project id, name, type, resourceGroup, location
264
+| project id, name, type, resourceGroup, location, tags
265
```
266
267
@@ -332,6 +335,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
335
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
336
337
338
+<a id="option-virtual-node-vnode"></a>
339
+##### vnode
340
+
341
+This job-level virtual node is used for metrics written to the default host scope.
342
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
343
+
344
+
345
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
346
+##### virtual_nodes.by_resource_tag
347
+
348
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
349
+
350
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
351
+- Empty or missing tag values use the default job host scope.
352
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
353
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
354
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
355
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
356
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
357
+
358
+
359
360
</details>
361
@@ -403,6 +427,85 @@ jobs:
427
client_secret: "your-client-secret"
428
429
```
430
+###### Workload virtual nodes from resource tags
431
+
432
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
433
+
434
+<details open><summary>Config</summary>
435
+
436
+```yaml
437
+jobs:
438
+ - name: prod-workloads
439
+ subscription_ids:
440
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
441
+ virtual_nodes:
442
+ by_resource_tag: workload
443
+ discovery:
444
+ mode: filters
445
+ profiles:
446
+ mode: auto
447
+ auth:
448
+ mode: service_principal
449
+ mode_service_principal:
450
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
451
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
452
+ client_secret: "your-client-secret"
453
+
454
+```
455
+</details>
456
+
457
+###### Custom KQL with workload virtual nodes
458
+
459
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
460
+
461
+<details open><summary>Config</summary>
462
+
463
+```yaml
464
+jobs:
465
+ - name: prod-query-workloads
466
+ subscription_ids:
467
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
468
+ virtual_nodes:
469
+ by_resource_tag: workload
470
+ discovery:
471
+ mode: query
472
+ mode_query:
473
+ kql: |
474
+ resources
475
+ | where tags.env =~ "prod"
476
+ | project id, name, type, resourceGroup, location, tags
477
+ profiles:
478
+ mode: auto
479
+ auth:
480
+ mode: default
481
+
482
+```
483
+</details>
484
+
485
+###### Job virtual node plus workload virtual nodes
486
+
487
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
488
+
489
+<details open><summary>Config</summary>
490
+
491
+```yaml
492
+jobs:
493
+ - name: prod-with-fallback-node
494
+ vnode: azure-fallback-node
495
+ subscription_ids:
496
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
497
+ virtual_nodes:
498
+ by_resource_tag: workload
499
+ discovery:
500
+ mode: filters
501
+ profiles:
502
+ mode: auto
503
+ auth:
504
+ mode: managed_identity
505
+
506
+```
507
+</details>
508
+
509
###### Managed identity with exact profiles
510
511
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -482,7 +585,7 @@ jobs:
585
kql: |
586
resources
587
| where tags.env =~ "prod"
485
- | project id, name, type, resourceGroup, location
588
+ | project id, name, type, resourceGroup, location, tags
589
profiles:
590
mode: auto
591
auth:
@@ -635,6 +738,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
738
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
739
740
741
+### Workload virtual nodes are not created
742
+
743
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
744
+
745
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
746
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
747
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
748
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
749
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
750
+
751
+
752
+### More alerts appear after enabling workload virtual nodes
753
+
754
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
755
+
756
+
757
### Authentication errors in sovereign clouds
758
759
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_mysql_flexible_server.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -721,6 +824,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
824
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
825
826
827
+### Workload virtual nodes are not created
828
+
829
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
830
+
831
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
832
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
833
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
834
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
835
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
836
+
837
+
838
+### More alerts appear after enabling workload virtual nodes
839
+
840
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
841
+
842
+
843
### Authentication errors in sovereign clouds
844
845
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_nat_gateway.md
+124
-5
@@ -187,7 +187,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
187
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
190
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
190
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
191
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
192
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
193
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -195,7 +195,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
195
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
196
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
197
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
198
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
200
201
<a id="option-collection-query-offset"></a>
202
##### query_offset
@@ -230,7 +231,7 @@ Controls how the collector finds candidate Azure resources.
231
| Mode | Behavior |
232
|:-----|:---------|
233
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
233
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
234
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
235
236
237
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -249,6 +250,8 @@ The query **must** project these five columns:
250
| `resourceGroup` | Resource group name |
251
| `location` | Azure region |
252
253
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
254
+
255
:::info
256
257
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -262,7 +265,7 @@ Example:
265
```
266
resources
267
| where tags.env =~ "prod"
265
-| project id, name, type, resourceGroup, location
268
+| project id, name, type, resourceGroup, location, tags
269
```
270
271
@@ -336,6 +339,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
339
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
340
341
342
+<a id="option-virtual-node-vnode"></a>
343
+##### vnode
344
+
345
+This job-level virtual node is used for metrics written to the default host scope.
346
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
347
+
348
+
349
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
350
+##### virtual_nodes.by_resource_tag
351
+
352
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
353
+
354
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
355
+- Empty or missing tag values use the default job host scope.
356
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
357
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
358
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
359
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
360
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
361
+
362
+
363
364
</details>
365
@@ -407,6 +431,85 @@ jobs:
431
client_secret: "your-client-secret"
432
433
```
434
+###### Workload virtual nodes from resource tags
435
+
436
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
437
+
438
+<details open><summary>Config</summary>
439
+
440
+```yaml
441
+jobs:
442
+ - name: prod-workloads
443
+ subscription_ids:
444
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
445
+ virtual_nodes:
446
+ by_resource_tag: workload
447
+ discovery:
448
+ mode: filters
449
+ profiles:
450
+ mode: auto
451
+ auth:
452
+ mode: service_principal
453
+ mode_service_principal:
454
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_secret: "your-client-secret"
457
+
458
+```
459
+</details>
460
+
461
+###### Custom KQL with workload virtual nodes
462
+
463
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
464
+
465
+<details open><summary>Config</summary>
466
+
467
+```yaml
468
+jobs:
469
+ - name: prod-query-workloads
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: query
476
+ mode_query:
477
+ kql: |
478
+ resources
479
+ | where tags.env =~ "prod"
480
+ | project id, name, type, resourceGroup, location, tags
481
+ profiles:
482
+ mode: auto
483
+ auth:
484
+ mode: default
485
+
486
+```
487
+</details>
488
+
489
+###### Job virtual node plus workload virtual nodes
490
+
491
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
492
+
493
+<details open><summary>Config</summary>
494
+
495
+```yaml
496
+jobs:
497
+ - name: prod-with-fallback-node
498
+ vnode: azure-fallback-node
499
+ subscription_ids:
500
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
501
+ virtual_nodes:
502
+ by_resource_tag: workload
503
+ discovery:
504
+ mode: filters
505
+ profiles:
506
+ mode: auto
507
+ auth:
508
+ mode: managed_identity
509
+
510
+```
511
+</details>
512
+
513
###### Managed identity with exact profiles
514
515
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -486,7 +589,7 @@ jobs:
589
kql: |
590
resources
591
| where tags.env =~ "prod"
489
- | project id, name, type, resourceGroup, location
592
+ | project id, name, type, resourceGroup, location, tags
593
profiles:
594
mode: auto
595
auth:
@@ -674,6 +777,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
777
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
778
779
780
+### Workload virtual nodes are not created
781
+
782
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
783
+
784
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
785
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
786
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
787
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
788
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
789
+
790
+
791
+### More alerts appear after enabling workload virtual nodes
792
+
793
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
794
+
795
+
796
### Authentication errors in sovereign clouds
797
798
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_postgresql_flexible_server.md
+124
-5
@@ -192,7 +192,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
192
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
195
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
195
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
196
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
197
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
198
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -200,7 +200,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
200
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
201
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
202
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
203
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
205
206
<a id="option-collection-query-offset"></a>
207
##### query_offset
@@ -235,7 +236,7 @@ Controls how the collector finds candidate Azure resources.
236
| Mode | Behavior |
237
|:-----|:---------|
238
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
238
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
239
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
240
241
242
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -254,6 +255,8 @@ The query **must** project these five columns:
255
| `resourceGroup` | Resource group name |
256
| `location` | Azure region |
257
258
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
259
+
260
:::info
261
262
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -267,7 +270,7 @@ Example:
270
```
271
resources
272
| where tags.env =~ "prod"
270
-| project id, name, type, resourceGroup, location
273
+| project id, name, type, resourceGroup, location, tags
274
```
275
276
@@ -341,6 +344,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
344
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
345
346
347
+<a id="option-virtual-node-vnode"></a>
348
+##### vnode
349
+
350
+This job-level virtual node is used for metrics written to the default host scope.
351
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
352
+
353
+
354
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
355
+##### virtual_nodes.by_resource_tag
356
+
357
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
358
+
359
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
360
+- Empty or missing tag values use the default job host scope.
361
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
362
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
363
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
364
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
365
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
366
+
367
+
368
369
</details>
370
@@ -412,6 +436,85 @@ jobs:
436
client_secret: "your-client-secret"
437
438
```
439
+###### Workload virtual nodes from resource tags
440
+
441
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
442
+
443
+<details open><summary>Config</summary>
444
+
445
+```yaml
446
+jobs:
447
+ - name: prod-workloads
448
+ subscription_ids:
449
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
450
+ virtual_nodes:
451
+ by_resource_tag: workload
452
+ discovery:
453
+ mode: filters
454
+ profiles:
455
+ mode: auto
456
+ auth:
457
+ mode: service_principal
458
+ mode_service_principal:
459
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_secret: "your-client-secret"
462
+
463
+```
464
+</details>
465
+
466
+###### Custom KQL with workload virtual nodes
467
+
468
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
469
+
470
+<details open><summary>Config</summary>
471
+
472
+```yaml
473
+jobs:
474
+ - name: prod-query-workloads
475
+ subscription_ids:
476
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
477
+ virtual_nodes:
478
+ by_resource_tag: workload
479
+ discovery:
480
+ mode: query
481
+ mode_query:
482
+ kql: |
483
+ resources
484
+ | where tags.env =~ "prod"
485
+ | project id, name, type, resourceGroup, location, tags
486
+ profiles:
487
+ mode: auto
488
+ auth:
489
+ mode: default
490
+
491
+```
492
+</details>
493
+
494
+###### Job virtual node plus workload virtual nodes
495
+
496
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
497
+
498
+<details open><summary>Config</summary>
499
+
500
+```yaml
501
+jobs:
502
+ - name: prod-with-fallback-node
503
+ vnode: azure-fallback-node
504
+ subscription_ids:
505
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
506
+ virtual_nodes:
507
+ by_resource_tag: workload
508
+ discovery:
509
+ mode: filters
510
+ profiles:
511
+ mode: auto
512
+ auth:
513
+ mode: managed_identity
514
+
515
+```
516
+</details>
517
+
518
###### Managed identity with exact profiles
519
520
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -491,7 +594,7 @@ jobs:
594
kql: |
595
resources
596
| where tags.env =~ "prod"
494
- | project id, name, type, resourceGroup, location
597
+ | project id, name, type, resourceGroup, location, tags
598
profiles:
599
mode: auto
600
auth:
@@ -731,6 +834,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
834
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
835
836
837
+### Workload virtual nodes are not created
838
+
839
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
840
+
841
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
842
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
843
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
844
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
845
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
846
+
847
+
848
+### More alerts appear after enabling workload virtual nodes
849
+
850
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
851
+
852
+
853
### Authentication errors in sovereign clouds
854
855
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_service_bus_namespace.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -696,6 +799,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
799
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
800
801
802
+### Workload virtual nodes are not created
803
+
804
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
805
+
806
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
807
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
808
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
809
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
810
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
811
+
812
+
813
+### More alerts appear after enabling workload virtual nodes
814
+
815
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
816
+
817
+
818
### Authentication errors in sovereign clouds
819
820
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_sql_database.md
+124
-5
@@ -190,7 +190,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
190
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
193
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
193
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
194
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
195
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
196
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -198,7 +198,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
198
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
199
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
200
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
201
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
203
204
<a id="option-collection-query-offset"></a>
205
##### query_offset
@@ -233,7 +234,7 @@ Controls how the collector finds candidate Azure resources.
234
| Mode | Behavior |
235
|:-----|:---------|
236
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
236
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
237
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
238
239
240
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -252,6 +253,8 @@ The query **must** project these five columns:
253
| `resourceGroup` | Resource group name |
254
| `location` | Azure region |
255
256
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
257
+
258
:::info
259
260
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -265,7 +268,7 @@ Example:
268
```
269
resources
270
| where tags.env =~ "prod"
268
-| project id, name, type, resourceGroup, location
271
+| project id, name, type, resourceGroup, location, tags
272
```
273
274
@@ -339,6 +342,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
342
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
343
344
345
+<a id="option-virtual-node-vnode"></a>
346
+##### vnode
347
+
348
+This job-level virtual node is used for metrics written to the default host scope.
349
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
350
+
351
+
352
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
353
+##### virtual_nodes.by_resource_tag
354
+
355
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
356
+
357
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
358
+- Empty or missing tag values use the default job host scope.
359
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
360
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
361
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
362
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
363
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
364
+
365
+
366
367
</details>
368
@@ -410,6 +434,85 @@ jobs:
434
client_secret: "your-client-secret"
435
436
```
437
+###### Workload virtual nodes from resource tags
438
+
439
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
440
+
441
+<details open><summary>Config</summary>
442
+
443
+```yaml
444
+jobs:
445
+ - name: prod-workloads
446
+ subscription_ids:
447
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
448
+ virtual_nodes:
449
+ by_resource_tag: workload
450
+ discovery:
451
+ mode: filters
452
+ profiles:
453
+ mode: auto
454
+ auth:
455
+ mode: service_principal
456
+ mode_service_principal:
457
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_secret: "your-client-secret"
460
+
461
+```
462
+</details>
463
+
464
+###### Custom KQL with workload virtual nodes
465
+
466
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
467
+
468
+<details open><summary>Config</summary>
469
+
470
+```yaml
471
+jobs:
472
+ - name: prod-query-workloads
473
+ subscription_ids:
474
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
475
+ virtual_nodes:
476
+ by_resource_tag: workload
477
+ discovery:
478
+ mode: query
479
+ mode_query:
480
+ kql: |
481
+ resources
482
+ | where tags.env =~ "prod"
483
+ | project id, name, type, resourceGroup, location, tags
484
+ profiles:
485
+ mode: auto
486
+ auth:
487
+ mode: default
488
+
489
+```
490
+</details>
491
+
492
+###### Job virtual node plus workload virtual nodes
493
+
494
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
495
+
496
+<details open><summary>Config</summary>
497
+
498
+```yaml
499
+jobs:
500
+ - name: prod-with-fallback-node
501
+ vnode: azure-fallback-node
502
+ subscription_ids:
503
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
504
+ virtual_nodes:
505
+ by_resource_tag: workload
506
+ discovery:
507
+ mode: filters
508
+ profiles:
509
+ mode: auto
510
+ auth:
511
+ mode: managed_identity
512
+
513
+```
514
+</details>
515
+
516
###### Managed identity with exact profiles
517
518
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -489,7 +592,7 @@ jobs:
592
kql: |
593
resources
594
| where tags.env =~ "prod"
492
- | project id, name, type, resourceGroup, location
595
+ | project id, name, type, resourceGroup, location, tags
596
profiles:
597
mode: auto
598
auth:
@@ -708,6 +811,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
811
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
812
813
814
+### Workload virtual nodes are not created
815
+
816
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
817
+
818
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
819
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
820
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
821
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
822
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
823
+
824
+
825
+### More alerts appear after enabling workload virtual nodes
826
+
827
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
828
+
829
+
830
### Authentication errors in sovereign clouds
831
832
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_sql_elastic_pool.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -698,6 +801,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
801
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
802
803
804
+### Workload virtual nodes are not created
805
+
806
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
807
+
808
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
809
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
810
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
811
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
812
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
813
+
814
+
815
+### More alerts appear after enabling workload virtual nodes
816
+
817
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
818
+
819
+
820
### Authentication errors in sovereign clouds
821
822
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_sql_managed_instance.md
+124
-5
@@ -186,7 +186,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
186
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
187
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
188
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
189
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
189
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
190
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
191
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
192
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -194,7 +194,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
194
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
195
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
196
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
197
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
197
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
198
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
199
200
<a id="option-collection-query-offset"></a>
201
##### query_offset
@@ -229,7 +230,7 @@ Controls how the collector finds candidate Azure resources.
230
| Mode | Behavior |
231
|:-----|:---------|
232
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
232
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
233
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
234
235
236
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -248,6 +249,8 @@ The query **must** project these five columns:
249
| `resourceGroup` | Resource group name |
250
| `location` | Azure region |
251
252
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
253
+
254
:::info
255
256
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -261,7 +264,7 @@ Example:
264
```
265
resources
266
| where tags.env =~ "prod"
264
-| project id, name, type, resourceGroup, location
267
+| project id, name, type, resourceGroup, location, tags
268
```
269
270
@@ -335,6 +338,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
338
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
339
340
341
+<a id="option-virtual-node-vnode"></a>
342
+##### vnode
343
+
344
+This job-level virtual node is used for metrics written to the default host scope.
345
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
346
+
347
+
348
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
349
+##### virtual_nodes.by_resource_tag
350
+
351
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
352
+
353
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
354
+- Empty or missing tag values use the default job host scope.
355
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
356
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
357
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
358
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
359
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
360
+
361
+
362
363
</details>
364
@@ -406,6 +430,85 @@ jobs:
430
client_secret: "your-client-secret"
431
432
```
433
+###### Workload virtual nodes from resource tags
434
+
435
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
436
+
437
+<details open><summary>Config</summary>
438
+
439
+```yaml
440
+jobs:
441
+ - name: prod-workloads
442
+ subscription_ids:
443
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
444
+ virtual_nodes:
445
+ by_resource_tag: workload
446
+ discovery:
447
+ mode: filters
448
+ profiles:
449
+ mode: auto
450
+ auth:
451
+ mode: service_principal
452
+ mode_service_principal:
453
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
454
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
455
+ client_secret: "your-client-secret"
456
+
457
+```
458
+</details>
459
+
460
+###### Custom KQL with workload virtual nodes
461
+
462
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
463
+
464
+<details open><summary>Config</summary>
465
+
466
+```yaml
467
+jobs:
468
+ - name: prod-query-workloads
469
+ subscription_ids:
470
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
471
+ virtual_nodes:
472
+ by_resource_tag: workload
473
+ discovery:
474
+ mode: query
475
+ mode_query:
476
+ kql: |
477
+ resources
478
+ | where tags.env =~ "prod"
479
+ | project id, name, type, resourceGroup, location, tags
480
+ profiles:
481
+ mode: auto
482
+ auth:
483
+ mode: default
484
+
485
+```
486
+</details>
487
+
488
+###### Job virtual node plus workload virtual nodes
489
+
490
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
491
+
492
+<details open><summary>Config</summary>
493
+
494
+```yaml
495
+jobs:
496
+ - name: prod-with-fallback-node
497
+ vnode: azure-fallback-node
498
+ subscription_ids:
499
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
500
+ virtual_nodes:
501
+ by_resource_tag: workload
502
+ discovery:
503
+ mode: filters
504
+ profiles:
505
+ mode: auto
506
+ auth:
507
+ mode: managed_identity
508
+
509
+```
510
+</details>
511
+
512
###### Managed identity with exact profiles
513
514
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -485,7 +588,7 @@ jobs:
588
kql: |
589
resources
590
| where tags.env =~ "prod"
488
- | project id, name, type, resourceGroup, location
591
+ | project id, name, type, resourceGroup, location, tags
592
profiles:
593
mode: auto
594
auth:
@@ -671,6 +774,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
774
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
775
776
777
+### Workload virtual nodes are not created
778
+
779
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
780
+
781
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
782
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
783
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
784
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
785
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
786
+
787
+
788
+### More alerts appear after enabling workload virtual nodes
789
+
790
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
791
+
792
+
793
### Authentication errors in sovereign clouds
794
795
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_storage_account.md
+124
-5
@@ -188,7 +188,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
188
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
191
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
191
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
192
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
193
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
194
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -196,7 +196,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
196
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
197
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
198
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
199
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
201
202
<a id="option-collection-query-offset"></a>
203
##### query_offset
@@ -231,7 +232,7 @@ Controls how the collector finds candidate Azure resources.
232
| Mode | Behavior |
233
|:-----|:---------|
234
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
234
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
235
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
236
237
238
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -250,6 +251,8 @@ The query **must** project these five columns:
251
| `resourceGroup` | Resource group name |
252
| `location` | Azure region |
253
254
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
255
+
256
:::info
257
258
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -263,7 +266,7 @@ Example:
266
```
267
resources
268
| where tags.env =~ "prod"
266
-| project id, name, type, resourceGroup, location
269
+| project id, name, type, resourceGroup, location, tags
270
```
271
272
@@ -337,6 +340,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
340
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
341
342
343
+<a id="option-virtual-node-vnode"></a>
344
+##### vnode
345
+
346
+This job-level virtual node is used for metrics written to the default host scope.
347
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
348
+
349
+
350
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
351
+##### virtual_nodes.by_resource_tag
352
+
353
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
354
+
355
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
356
+- Empty or missing tag values use the default job host scope.
357
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
358
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
359
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
360
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
361
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
362
+
363
+
364
365
</details>
366
@@ -408,6 +432,85 @@ jobs:
432
client_secret: "your-client-secret"
433
434
```
435
+###### Workload virtual nodes from resource tags
436
+
437
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
438
+
439
+<details open><summary>Config</summary>
440
+
441
+```yaml
442
+jobs:
443
+ - name: prod-workloads
444
+ subscription_ids:
445
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
446
+ virtual_nodes:
447
+ by_resource_tag: workload
448
+ discovery:
449
+ mode: filters
450
+ profiles:
451
+ mode: auto
452
+ auth:
453
+ mode: service_principal
454
+ mode_service_principal:
455
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_secret: "your-client-secret"
458
+
459
+```
460
+</details>
461
+
462
+###### Custom KQL with workload virtual nodes
463
+
464
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
465
+
466
+<details open><summary>Config</summary>
467
+
468
+```yaml
469
+jobs:
470
+ - name: prod-query-workloads
471
+ subscription_ids:
472
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
473
+ virtual_nodes:
474
+ by_resource_tag: workload
475
+ discovery:
476
+ mode: query
477
+ mode_query:
478
+ kql: |
479
+ resources
480
+ | where tags.env =~ "prod"
481
+ | project id, name, type, resourceGroup, location, tags
482
+ profiles:
483
+ mode: auto
484
+ auth:
485
+ mode: default
486
+
487
+```
488
+</details>
489
+
490
+###### Job virtual node plus workload virtual nodes
491
+
492
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
493
+
494
+<details open><summary>Config</summary>
495
+
496
+```yaml
497
+jobs:
498
+ - name: prod-with-fallback-node
499
+ vnode: azure-fallback-node
500
+ subscription_ids:
501
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
502
+ virtual_nodes:
503
+ by_resource_tag: workload
504
+ discovery:
505
+ mode: filters
506
+ profiles:
507
+ mode: auto
508
+ auth:
509
+ mode: managed_identity
510
+
511
+```
512
+</details>
513
+
514
###### Managed identity with exact profiles
515
516
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -487,7 +590,7 @@ jobs:
590
kql: |
591
resources
592
| where tags.env =~ "prod"
490
- | project id, name, type, resourceGroup, location
593
+ | project id, name, type, resourceGroup, location, tags
594
profiles:
595
mode: auto
596
auth:
@@ -676,6 +779,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
779
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
780
781
782
+### Workload virtual nodes are not created
783
+
784
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
785
+
786
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
787
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
788
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
789
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
790
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
791
+
792
+
793
+### More alerts appear after enabling workload virtual nodes
794
+
795
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
796
+
797
+
798
### Authentication errors in sovereign clouds
799
800
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_stream_analytics_job.md
+124
-5
@@ -189,7 +189,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
189
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
191
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
192
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
192
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
193
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
194
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
195
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -197,7 +197,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
197
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
198
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
199
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
200
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
201
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
202
203
<a id="option-collection-query-offset"></a>
204
##### query_offset
@@ -232,7 +233,7 @@ Controls how the collector finds candidate Azure resources.
233
| Mode | Behavior |
234
|:-----|:---------|
235
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
235
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
236
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
237
238
239
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -251,6 +252,8 @@ The query **must** project these five columns:
252
| `resourceGroup` | Resource group name |
253
| `location` | Azure region |
254
255
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
256
+
257
:::info
258
259
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -264,7 +267,7 @@ Example:
267
```
268
resources
269
| where tags.env =~ "prod"
267
-| project id, name, type, resourceGroup, location
270
+| project id, name, type, resourceGroup, location, tags
271
```
272
273
@@ -338,6 +341,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
341
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
342
343
344
+<a id="option-virtual-node-vnode"></a>
345
+##### vnode
346
+
347
+This job-level virtual node is used for metrics written to the default host scope.
348
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
349
+
350
+
351
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
352
+##### virtual_nodes.by_resource_tag
353
+
354
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
355
+
356
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
357
+- Empty or missing tag values use the default job host scope.
358
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
359
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
360
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
361
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
362
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
363
+
364
+
365
366
</details>
367
@@ -409,6 +433,85 @@ jobs:
433
client_secret: "your-client-secret"
434
435
```
436
+###### Workload virtual nodes from resource tags
437
+
438
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
439
+
440
+<details open><summary>Config</summary>
441
+
442
+```yaml
443
+jobs:
444
+ - name: prod-workloads
445
+ subscription_ids:
446
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
447
+ virtual_nodes:
448
+ by_resource_tag: workload
449
+ discovery:
450
+ mode: filters
451
+ profiles:
452
+ mode: auto
453
+ auth:
454
+ mode: service_principal
455
+ mode_service_principal:
456
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
458
+ client_secret: "your-client-secret"
459
+
460
+```
461
+</details>
462
+
463
+###### Custom KQL with workload virtual nodes
464
+
465
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
466
+
467
+<details open><summary>Config</summary>
468
+
469
+```yaml
470
+jobs:
471
+ - name: prod-query-workloads
472
+ subscription_ids:
473
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
474
+ virtual_nodes:
475
+ by_resource_tag: workload
476
+ discovery:
477
+ mode: query
478
+ mode_query:
479
+ kql: |
480
+ resources
481
+ | where tags.env =~ "prod"
482
+ | project id, name, type, resourceGroup, location, tags
483
+ profiles:
484
+ mode: auto
485
+ auth:
486
+ mode: default
487
+
488
+```
489
+</details>
490
+
491
+###### Job virtual node plus workload virtual nodes
492
+
493
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
494
+
495
+<details open><summary>Config</summary>
496
+
497
+```yaml
498
+jobs:
499
+ - name: prod-with-fallback-node
500
+ vnode: azure-fallback-node
501
+ subscription_ids:
502
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
503
+ virtual_nodes:
504
+ by_resource_tag: workload
505
+ discovery:
506
+ mode: filters
507
+ profiles:
508
+ mode: auto
509
+ auth:
510
+ mode: managed_identity
511
+
512
+```
513
+</details>
514
+
515
###### Managed identity with exact profiles
516
517
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -488,7 +591,7 @@ jobs:
591
kql: |
592
resources
593
| where tags.env =~ "prod"
491
- | project id, name, type, resourceGroup, location
594
+ | project id, name, type, resourceGroup, location, tags
595
profiles:
596
mode: auto
597
auth:
@@ -686,6 +789,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
789
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
790
791
792
+### Workload virtual nodes are not created
793
+
794
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
795
+
796
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
797
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
798
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
799
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
800
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
801
+
802
+
803
+### More alerts appear after enabling workload virtual nodes
804
+
805
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
806
+
807
+
808
### Authentication errors in sovereign clouds
809
810
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_synapse_analytics_workspace.md
+124
-5
@@ -188,7 +188,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
188
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
189
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
190
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
191
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
191
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
192
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
193
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
194
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -196,7 +196,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
196
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
197
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
198
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
199
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
199
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
200
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
201
202
<a id="option-collection-query-offset"></a>
203
##### query_offset
@@ -231,7 +232,7 @@ Controls how the collector finds candidate Azure resources.
232
| Mode | Behavior |
233
|:-----|:---------|
234
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
234
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
235
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
236
237
238
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -250,6 +251,8 @@ The query **must** project these five columns:
251
| `resourceGroup` | Resource group name |
252
| `location` | Azure region |
253
254
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
255
+
256
:::info
257
258
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -263,7 +266,7 @@ Example:
266
```
267
resources
268
| where tags.env =~ "prod"
266
-| project id, name, type, resourceGroup, location
269
+| project id, name, type, resourceGroup, location, tags
270
```
271
272
@@ -337,6 +340,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
340
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
341
342
343
+<a id="option-virtual-node-vnode"></a>
344
+##### vnode
345
+
346
+This job-level virtual node is used for metrics written to the default host scope.
347
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
348
+
349
+
350
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
351
+##### virtual_nodes.by_resource_tag
352
+
353
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
354
+
355
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
356
+- Empty or missing tag values use the default job host scope.
357
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
358
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
359
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
360
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
361
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
362
+
363
+
364
365
</details>
366
@@ -408,6 +432,85 @@ jobs:
432
client_secret: "your-client-secret"
433
434
```
435
+###### Workload virtual nodes from resource tags
436
+
437
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
438
+
439
+<details open><summary>Config</summary>
440
+
441
+```yaml
442
+jobs:
443
+ - name: prod-workloads
444
+ subscription_ids:
445
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
446
+ virtual_nodes:
447
+ by_resource_tag: workload
448
+ discovery:
449
+ mode: filters
450
+ profiles:
451
+ mode: auto
452
+ auth:
453
+ mode: service_principal
454
+ mode_service_principal:
455
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
456
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
457
+ client_secret: "your-client-secret"
458
+
459
+```
460
+</details>
461
+
462
+###### Custom KQL with workload virtual nodes
463
+
464
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
465
+
466
+<details open><summary>Config</summary>
467
+
468
+```yaml
469
+jobs:
470
+ - name: prod-query-workloads
471
+ subscription_ids:
472
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
473
+ virtual_nodes:
474
+ by_resource_tag: workload
475
+ discovery:
476
+ mode: query
477
+ mode_query:
478
+ kql: |
479
+ resources
480
+ | where tags.env =~ "prod"
481
+ | project id, name, type, resourceGroup, location, tags
482
+ profiles:
483
+ mode: auto
484
+ auth:
485
+ mode: default
486
+
487
+```
488
+</details>
489
+
490
+###### Job virtual node plus workload virtual nodes
491
+
492
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
493
+
494
+<details open><summary>Config</summary>
495
+
496
+```yaml
497
+jobs:
498
+ - name: prod-with-fallback-node
499
+ vnode: azure-fallback-node
500
+ subscription_ids:
501
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
502
+ virtual_nodes:
503
+ by_resource_tag: workload
504
+ discovery:
505
+ mode: filters
506
+ profiles:
507
+ mode: auto
508
+ auth:
509
+ mode: managed_identity
510
+
511
+```
512
+</details>
513
+
514
###### Managed identity with exact profiles
515
516
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -487,7 +590,7 @@ jobs:
590
kql: |
591
resources
592
| where tags.env =~ "prod"
490
- | project id, name, type, resourceGroup, location
593
+ | project id, name, type, resourceGroup, location, tags
594
profiles:
595
mode: auto
596
auth:
@@ -691,6 +794,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
794
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
795
796
797
+### Workload virtual nodes are not created
798
+
799
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
800
+
801
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
802
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
803
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
804
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
805
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
806
+
807
+
808
+### More alerts appear after enabling workload virtual nodes
809
+
810
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
811
+
812
+
813
### Authentication errors in sovereign clouds
814
815
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_virtual_machine.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -728,6 +831,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
831
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
832
833
834
+### Workload virtual nodes are not created
835
+
836
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
837
+
838
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
839
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
840
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
841
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
842
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
843
+
844
+
845
+### More alerts appear after enabling workload virtual nodes
846
+
847
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
848
+
849
+
850
### Authentication errors in sovereign clouds
851
852
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_virtual_machine_scale_set.md
+124
-5
@@ -193,7 +193,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
193
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
194
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
195
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
196
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
196
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
197
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
198
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
199
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -201,7 +201,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
201
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
202
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
203
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
204
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
204
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
205
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
206
207
<a id="option-collection-query-offset"></a>
208
##### query_offset
@@ -236,7 +237,7 @@ Controls how the collector finds candidate Azure resources.
237
| Mode | Behavior |
238
|:-----|:---------|
239
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
239
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
240
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
241
242
243
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -255,6 +256,8 @@ The query **must** project these five columns:
256
| `resourceGroup` | Resource group name |
257
| `location` | Azure region |
258
259
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
260
+
261
:::info
262
263
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -268,7 +271,7 @@ Example:
271
```
272
resources
273
| where tags.env =~ "prod"
271
-| project id, name, type, resourceGroup, location
274
+| project id, name, type, resourceGroup, location, tags
275
```
276
277
@@ -342,6 +345,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
345
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
346
347
348
+<a id="option-virtual-node-vnode"></a>
349
+##### vnode
350
+
351
+This job-level virtual node is used for metrics written to the default host scope.
352
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
353
+
354
+
355
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
356
+##### virtual_nodes.by_resource_tag
357
+
358
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
359
+
360
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
361
+- Empty or missing tag values use the default job host scope.
362
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
363
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
364
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
365
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
366
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
367
+
368
+
369
370
</details>
371
@@ -413,6 +437,85 @@ jobs:
437
client_secret: "your-client-secret"
438
439
```
440
+###### Workload virtual nodes from resource tags
441
+
442
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
443
+
444
+<details open><summary>Config</summary>
445
+
446
+```yaml
447
+jobs:
448
+ - name: prod-workloads
449
+ subscription_ids:
450
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
451
+ virtual_nodes:
452
+ by_resource_tag: workload
453
+ discovery:
454
+ mode: filters
455
+ profiles:
456
+ mode: auto
457
+ auth:
458
+ mode: service_principal
459
+ mode_service_principal:
460
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
461
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
462
+ client_secret: "your-client-secret"
463
+
464
+```
465
+</details>
466
+
467
+###### Custom KQL with workload virtual nodes
468
+
469
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
470
+
471
+<details open><summary>Config</summary>
472
+
473
+```yaml
474
+jobs:
475
+ - name: prod-query-workloads
476
+ subscription_ids:
477
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
478
+ virtual_nodes:
479
+ by_resource_tag: workload
480
+ discovery:
481
+ mode: query
482
+ mode_query:
483
+ kql: |
484
+ resources
485
+ | where tags.env =~ "prod"
486
+ | project id, name, type, resourceGroup, location, tags
487
+ profiles:
488
+ mode: auto
489
+ auth:
490
+ mode: default
491
+
492
+```
493
+</details>
494
+
495
+###### Job virtual node plus workload virtual nodes
496
+
497
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
498
+
499
+<details open><summary>Config</summary>
500
+
501
+```yaml
502
+jobs:
503
+ - name: prod-with-fallback-node
504
+ vnode: azure-fallback-node
505
+ subscription_ids:
506
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
507
+ virtual_nodes:
508
+ by_resource_tag: workload
509
+ discovery:
510
+ mode: filters
511
+ profiles:
512
+ mode: auto
513
+ auth:
514
+ mode: managed_identity
515
+
516
+```
517
+</details>
518
+
519
###### Managed identity with exact profiles
520
521
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -492,7 +595,7 @@ jobs:
595
kql: |
596
resources
597
| where tags.env =~ "prod"
495
- | project id, name, type, resourceGroup, location
598
+ | project id, name, type, resourceGroup, location, tags
599
profiles:
600
mode: auto
601
auth:
@@ -732,6 +835,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
835
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
836
837
838
+### Workload virtual nodes are not created
839
+
840
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
841
+
842
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
843
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
844
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
845
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
846
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
847
+
848
+
849
+### More alerts appear after enabling workload virtual nodes
850
+
851
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
852
+
853
+
854
### Authentication errors in sovereign clouds
855
856
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/integrations/azure_vpn_gateway.md
+124
-5
@@ -191,7 +191,7 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
191
| | discovery.mode_filters.resource_groups | Optional list of Azure resource groups to include in `filters` mode. | [] | no |
192
| | discovery.mode_filters.regions | Optional list of Azure regions to include in `filters` mode. | [] | no |
193
| | discovery.mode_filters.tags | Optional exact-match tag filters for `filters` mode. Keys are matched case-insensitively and values case-sensitively. | {} | no |
194
-| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`. | | no |
194
+| | [discovery.mode_query.kql](#option-discovery-discovery-mode-query-kql) | Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`. | | no |
195
| **Profiles** | [profiles.mode](#option-profiles-profiles-mode) | How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both). | auto | no |
196
| | [profiles.mode_auto.entries](#option-profiles-profiles-mode-auto-entries) | Optional per-profile overrides applied only to profiles that auto-activate at bootstrap. | [] | no |
197
| | [profiles.mode_exact.entries](#option-profiles-profiles-mode-exact-entries) | Explicit profile entries used by `exact` mode. | [] | no |
@@ -199,7 +199,8 @@ Custom profiles extend the collector's catalog -- they do not replace the discov
199
| **Limits** | limits.max_concurrency | Maximum concurrent batch queries to Azure Monitor. | 4 | no |
200
| | limits.max_batch_resources | Maximum resources per Azure Monitor batch request. | 50 | no |
201
| | limits.max_metrics_per_query | Maximum metrics per Azure Monitor batch request. | 20 | no |
202
-| **Virtual Node** | vnode | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
202
+| **Virtual Node** | [vnode](#option-virtual-node-vnode) | Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes). | | no |
203
+| | [virtual_nodes.by_resource_tag](#option-virtual-node-virtual-nodes-by-resource-tag) | Creates workload virtual nodes from an Azure resource tag value. | | no |
204
205
<a id="option-collection-query-offset"></a>
206
##### query_offset
@@ -234,7 +235,7 @@ Controls how the collector finds candidate Azure resources.
235
| Mode | Behavior |
236
|:-----|:---------|
237
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
237
-| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
238
+| `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
239
240
241
<a id="option-discovery-discovery-mode-query-kql"></a>
@@ -253,6 +254,8 @@ The query **must** project these five columns:
254
| `resourceGroup` | Resource group name |
255
| `location` | Azure region |
256
257
+If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
258
+
259
:::info
260
261
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -266,7 +269,7 @@ Example:
269
```
270
resources
271
| where tags.env =~ "prod"
269
-| project id, name, type, resourceGroup, location
272
+| project id, name, type, resourceGroup, location, tags
273
```
274
275
@@ -340,6 +343,27 @@ If an explicit `combined` entry matches an auto-selected profile, the collector
343
In `discovery.mode: query`, only `filters.resource_groups` and `filters.regions` apply.
344
345
346
+<a id="option-virtual-node-vnode"></a>
347
+##### vnode
348
+
349
+This job-level virtual node is used for metrics written to the default host scope.
350
+If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
351
+
352
+
353
+<a id="option-virtual-node-virtual-nodes-by-resource-tag"></a>
354
+##### virtual_nodes.by_resource_tag
355
+
356
+Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
357
+
358
+- Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
359
+- Empty or missing tag values use the default job host scope.
360
+- Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
361
+- The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
362
+- Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
363
+- Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
364
+- The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
365
+
366
+
367
368
</details>
369
@@ -411,6 +435,85 @@ jobs:
435
client_secret: "your-client-secret"
436
437
```
438
+###### Workload virtual nodes from resource tags
439
+
440
+Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
441
+
442
+<details open><summary>Config</summary>
443
+
444
+```yaml
445
+jobs:
446
+ - name: prod-workloads
447
+ subscription_ids:
448
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
449
+ virtual_nodes:
450
+ by_resource_tag: workload
451
+ discovery:
452
+ mode: filters
453
+ profiles:
454
+ mode: auto
455
+ auth:
456
+ mode: service_principal
457
+ mode_service_principal:
458
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
459
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
460
+ client_secret: "your-client-secret"
461
+
462
+```
463
+</details>
464
+
465
+###### Custom KQL with workload virtual nodes
466
+
467
+In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
468
+
469
+<details open><summary>Config</summary>
470
+
471
+```yaml
472
+jobs:
473
+ - name: prod-query-workloads
474
+ subscription_ids:
475
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
476
+ virtual_nodes:
477
+ by_resource_tag: workload
478
+ discovery:
479
+ mode: query
480
+ mode_query:
481
+ kql: |
482
+ resources
483
+ | where tags.env =~ "prod"
484
+ | project id, name, type, resourceGroup, location, tags
485
+ profiles:
486
+ mode: auto
487
+ auth:
488
+ mode: default
489
+
490
+```
491
+</details>
492
+
493
+###### Job virtual node plus workload virtual nodes
494
+
495
+Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
496
+
497
+<details open><summary>Config</summary>
498
+
499
+```yaml
500
+jobs:
501
+ - name: prod-with-fallback-node
502
+ vnode: azure-fallback-node
503
+ subscription_ids:
504
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
505
+ virtual_nodes:
506
+ by_resource_tag: workload
507
+ discovery:
508
+ mode: filters
509
+ profiles:
510
+ mode: auto
511
+ auth:
512
+ mode: managed_identity
513
+
514
+```
515
+</details>
516
+
517
###### Managed identity with exact profiles
518
519
Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
@@ -490,7 +593,7 @@ jobs:
593
kql: |
594
resources
595
| where tags.env =~ "prod"
493
- | project id, name, type, resourceGroup, location
596
+ | project id, name, type, resourceGroup, location, tags
597
profiles:
598
mode: auto
599
auth:
@@ -721,6 +824,22 @@ Azure Monitor metrics have a built-in reporting delay of **1-3 minutes**.
824
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
825
826
827
+### Workload virtual nodes are not created
828
+
829
+When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
830
+
831
+- The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
832
+- In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
833
+- Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
834
+- The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
835
+- Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
836
+
837
+
838
+### More alerts appear after enabling workload virtual nodes
839
+
840
+Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
841
+
842
+
843
### Authentication errors in sovereign clouds
844
845
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/metadata.yaml
+91
-4
@@ -225,7 +225,7 @@ modules:
225
| Mode | Behavior |
226
|:-----|:---------|
227
| `filters` | Builds an Azure Resource Graph query from the structured `mode_filters.*` options (resource groups, regions, tags). This is the default. |
228
- | `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. |
228
+ | `query` | Uses the raw KQL you provide in `discovery.mode_query.kql`. The query must project `id`, `name`, `type`, `resourceGroup`, and `location`. Project `tags` too when using `virtual_nodes.by_resource_tag`. |
229
- name: discovery.mode_filters.resource_groups
230
description: Optional list of Azure resource groups to include in `filters` mode.
231
default_value: "[]"
@@ -242,7 +242,7 @@ modules:
242
required: false
243
group: Discovery
244
- name: discovery.mode_query.kql
245
- description: Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`.
245
+ description: Custom Azure Resource Graph KQL for `query` mode. Must project `id`, `name`, `type`, `resourceGroup`, `location`; project `tags` too when using `virtual_nodes.by_resource_tag`.
246
default_value: ""
247
required: false
248
group: Discovery
@@ -260,6 +260,8 @@ modules:
260
| `resourceGroup` | Resource group name |
261
| `location` | Azure region |
262
263
+ If `virtual_nodes.by_resource_tag` is set, also project `tags`. Missing `tags` or non-object-shaped `tags` do not fail discovery; affected resources fall back to the default job host scope and the collector logs a warning once per successful discovery refresh.
264
+
265
:::info
266
267
- Returned resource `type` values should match the Azure resource types expected by the active profiles.
@@ -273,7 +275,7 @@ modules:
275
```
276
resources
277
| where tags.env =~ "prod"
276
- | project id, name, type, resourceGroup, location
278
+ | project id, name, type, resourceGroup, location, tags
279
```
280
- name: profiles.mode
281
description: "How profiles are selected: `auto` (discover from resources), `exact` (explicit list), or `combined` (both)."
@@ -369,6 +371,24 @@ modules:
371
default_value: ""
372
required: false
373
group: Virtual Node
374
+ detailed_description: |
375
+ This job-level virtual node is used for metrics written to the default host scope.
376
+ If `virtual_nodes.by_resource_tag` is also set, resources that have a safe value for the configured tag are routed to workload virtual nodes instead. Resources without that tag, with an empty value, or with an unsafe hostname value continue to use the job-level `vnode` when configured.
377
+ - name: virtual_nodes.by_resource_tag
378
+ description: Creates workload virtual nodes from an Azure resource tag value.
379
+ default_value: ""
380
+ required: false
381
+ group: Virtual Node
382
+ detailed_description: |
383
+ Set this to an Azure resource tag key, for example `workload`, to route resource metrics under virtual nodes named after that tag value.
384
+
385
+ - Tag keys are matched case-insensitively; tag values keep their Azure case and are trimmed.
386
+ - Empty or missing tag values use the default job host scope.
387
+ - Unsafe hostname values use the default job host scope and are summarized in a warning once per successful discovery refresh.
388
+ - The virtual node GUID is deterministic from `azure_monitor:` plus the trimmed tag value, so the same Azure workload value maps to the same Azure Monitor virtual node across jobs and subscriptions.
389
+ - Virtual node metadata includes `_vnode_type=azure_workload`. Netdata also adds `_hostname` when emitting the virtual node definition.
390
+ - Workload virtual nodes increase host and alert cardinality. Health alerts defined for Azure Monitor charts attach per workload virtual node.
391
+ - The metrix scoped vec cache grows with observed workload scope and label cardinality during the collector runtime; keep the configured tag's cardinality bounded by operator policy.
392
examples:
393
folding:
394
title: Config
@@ -402,6 +422,61 @@ modules:
422
tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
423
client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
424
client_secret: "your-client-secret"
425
+ - name: Workload virtual nodes from resource tags
426
+ description: Route resources with `tags.workload` to virtual nodes named after the workload value. Resources without the tag stay on the default job host scope.
427
+ config: |
428
+ jobs:
429
+ - name: prod-workloads
430
+ subscription_ids:
431
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
432
+ virtual_nodes:
433
+ by_resource_tag: workload
434
+ discovery:
435
+ mode: filters
436
+ profiles:
437
+ mode: auto
438
+ auth:
439
+ mode: service_principal
440
+ mode_service_principal:
441
+ tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
442
+ client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
443
+ client_secret: "your-client-secret"
444
+ - name: Custom KQL with workload virtual nodes
445
+ description: In `query` discovery mode, project `tags` so `virtual_nodes.by_resource_tag` can derive workload virtual nodes.
446
+ config: |
447
+ jobs:
448
+ - name: prod-query-workloads
449
+ subscription_ids:
450
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
451
+ virtual_nodes:
452
+ by_resource_tag: workload
453
+ discovery:
454
+ mode: query
455
+ mode_query:
456
+ kql: |
457
+ resources
458
+ | where tags.env =~ "prod"
459
+ | project id, name, type, resourceGroup, location, tags
460
+ profiles:
461
+ mode: auto
462
+ auth:
463
+ mode: default
464
+ - name: Job virtual node plus workload virtual nodes
465
+ description: Use a job-level `vnode` as the default host scope while tagged resources route to workload virtual nodes.
466
+ config: |
467
+ jobs:
468
+ - name: prod-with-fallback-node
469
+ vnode: azure-fallback-node
470
+ subscription_ids:
471
+ - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ virtual_nodes:
473
+ by_resource_tag: workload
474
+ discovery:
475
+ mode: filters
476
+ profiles:
477
+ mode: auto
478
+ auth:
479
+ mode: managed_identity
480
- name: Managed identity with exact profiles
481
description: Use a managed identity (on an Azure VM, VMSS, or AKS) and monitor only SQL Database and PostgreSQL Flexible Server resources -- skip auto-discovery of other services.
482
config: |
@@ -461,7 +536,7 @@ modules:
536
kql: |
537
resources
538
| where tags.env =~ "prod"
464
- | project id, name, type, resourceGroup, location
539
+ | project id, name, type, resourceGroup, location, tags
540
profiles:
541
mode: auto
542
auth:
@@ -515,6 +590,18 @@ modules:
590
- The collector uses `query_offset` (default: **180 seconds**) as the minimum offset for metric query windows.
591
- Slower time-grain batches automatically use a larger effective offset when needed.
592
- If metrics are still missing or incomplete, increase `query_offset` to **240** or **300** seconds.
593
+ - name: Workload virtual nodes are not created
594
+ description: |
595
+ When `virtual_nodes.by_resource_tag` is set, check the discovered resource tags:
596
+
597
+ - The configured tag key is matched case-insensitively, but the value must be non-empty after trimming.
598
+ - In `discovery.mode: query`, the KQL must project `tags`. If the `tags` column is missing or not an object, the collector logs a warning and affected resources use the default job host scope.
599
+ - Tag values must be safe Netdata hostnames. Unsafe values are summarized in a warning once per successful discovery refresh and affected resources use the default job host scope.
600
+ - The same Azure workload value intentionally maps to the same Azure Monitor virtual node across subscriptions and jobs. Use distinct tag values when tenants should remain separate.
601
+ - Azure Monitor namespaces workload GUID input with `azure_monitor:` to avoid accidental collisions with other collectors that derive virtual node GUIDs from hostnames.
602
+ - name: More alerts appear after enabling workload virtual nodes
603
+ description: |
604
+ Workload virtual nodes multiply the host scope of Azure Monitor charts. Health alerts attached to those charts evaluate per workload virtual node, so alert volume can increase with the number of distinct workload tag values.
605
- name: Authentication errors in sovereign clouds
606
description: |
607
For Azure Government or Azure China clouds, set the `cloud` parameter:
src/go/plugin/go.d/collector/azure_monitor/observation_state.go
+45
-32
@@ -3,29 +3,40 @@
3
package azure_monitor
4
5
import (
6
- "strings"
7
-
6
"github.com/netdata/netdata/go/plugins/pkg/metrix"
7
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/azure_monitor/azureprofiles"
8
)
9
10
+type observationLabelIdentity [7]string
11
+
12
+type scopedLabelIdentity struct {
13
+ ScopeKey string
14
+ Labels observationLabelIdentity
15
+}
16
+
17
+type observationKey struct {
18
+ Instrument string
19
+ ScopeKey string
20
+ Labels observationLabelIdentity
21
+}
22
+
23
type observationState struct {
24
instruments map[string]*instrumentRuntime
14
- accumulators map[string]float64
15
- lastObserved map[string]lastObservation
25
+ accumulators map[observationKey]float64
26
+ lastObserved map[observationKey]lastObservation
27
}
28
29
func newObservationState(instruments map[string]*instrumentRuntime) *observationState {
30
return &observationState{
31
instruments: instruments,
21
- accumulators: make(map[string]float64),
22
- lastObserved: make(map[string]lastObservation),
32
+ accumulators: make(map[observationKey]float64),
33
+ lastObserved: make(map[observationKey]lastObservation),
34
}
35
}
36
37
func (s *observationState) reset() {
27
- s.accumulators = make(map[string]float64)
28
- s.lastObserved = make(map[string]lastObservation)
38
+ s.accumulators = make(map[observationKey]float64)
39
+ s.lastObserved = make(map[observationKey]lastObservation)
40
}
41
42
func dueInstrumentsForBatches(batches []queryBatch) map[string]bool {
@@ -40,8 +51,8 @@ func dueInstrumentsForBatches(batches []queryBatch) map[string]bool {
51
return dueInstruments
52
}
53
43
-func (s *observationState) observeSamples(samples []metricSample) map[string]bool {
44
- observedThisCycle := make(map[string]bool, len(s.lastObserved))
54
+func (s *observationState) observeSamples(samples []metricSample) map[observationKey]bool {
55
+ observedThisCycle := make(map[observationKey]bool, len(s.lastObserved))
56
for _, sample := range samples {
57
key, ok := s.observeSample(sample)
58
if !ok {
@@ -52,14 +63,14 @@ func (s *observationState) observeSamples(samples []metricSample) map[string]boo
63
return observedThisCycle
64
}
65
55
-func (s *observationState) observeSample(sample metricSample) (string, bool) {
66
+func (s *observationState) observeSample(sample metricSample) (observationKey, bool) {
67
inst, ok := s.instruments[sample.Instrument]
68
if !ok {
58
- return "", false
69
+ return observationKey{}, false
70
}
71
72
values := labelValues(sample.Labels)
62
- key := sampleObservationKey(sample.Instrument, values)
73
+ key := sampleObservationKey(sample.Instrument, sample.Scope, values)
74
value := sample.Value
75
76
if sample.Kind == azureprofiles.SeriesKindCounter {
@@ -67,9 +78,10 @@ func (s *observationState) observeSample(sample metricSample) (string, bool) {
78
value = s.accumulators[key]
79
}
80
70
- inst.observe(values, value)
81
+ inst.observe(sample.Scope, values, value)
82
s.lastObserved[key] = lastObservation{
83
instrument: sample.Instrument,
84
+ scope: sample.Scope,
85
labelValues: append([]string(nil), values...),
86
value: value,
87
}
@@ -77,7 +89,7 @@ func (s *observationState) observeSample(sample metricSample) (string, bool) {
89
return key, true
90
}
91
80
-func (s *observationState) reobserveCachedObservations(dueInstruments, observedThisCycle map[string]bool) {
92
+func (s *observationState) reobserveCachedObservations(dueInstruments map[string]bool, observedThisCycle map[observationKey]bool) {
93
for key, obs := range s.lastObserved {
94
if observedThisCycle[key] {
95
continue
@@ -89,17 +101,20 @@ func (s *observationState) reobserveCachedObservations(dueInstruments, observedT
101
if !ok {
102
continue
103
}
92
- inst.observe(obs.labelValues, obs.value)
104
+ inst.observe(obs.scope, obs.labelValues, obs.value)
105
}
106
}
107
108
// pruneStaleResources removes cache entries for resources that are no longer
109
// active for a specific profile/label identity.
110
func (s *observationState) pruneStaleResources(current map[string][]resourceInfo) {
99
- activeLabels := make(map[string]struct{})
111
+ activeLabels := make(map[scopedLabelIdentity]struct{})
112
for profileName, resources := range current {
113
for _, resource := range resources {
102
- activeLabels[labelIdentity(labelValues(resourceLabels(resource, profileName)))] = struct{}{}
114
+ activeLabels[scopedLabelIdentity{
115
+ ScopeKey: resource.HostScope.ScopeKey,
116
+ Labels: labelIdentity(labelValues(resourceLabels(resource, profileName))),
117
+ }] = struct{}{}
118
}
119
}
120
@@ -107,34 +122,32 @@ func (s *observationState) pruneStaleResources(current map[string][]resourceInfo
122
if len(obs.labelValues) == 0 {
123
continue
124
}
110
- if _, ok := activeLabels[labelIdentity(obs.labelValues)]; ok {
125
+ if _, ok := activeLabels[scopedLabelIdentity{ScopeKey: obs.scope.ScopeKey, Labels: labelIdentity(obs.labelValues)}]; ok {
126
continue
127
}
128
delete(s.lastObserved, key)
129
}
130
131
for key := range s.accumulators {
117
- if _, ok := activeLabels[labelIdentityFromObservationKey(key)]; ok {
132
+ if _, ok := activeLabels[scopedLabelIdentity{ScopeKey: key.ScopeKey, Labels: key.Labels}]; ok {
133
continue
134
}
135
delete(s.accumulators, key)
136
}
137
}
138
124
-func sampleObservationKey(instrument string, values []string) string {
125
- return instrument + "\x00" + strings.Join(values, "\x00")
126
-}
127
-
128
-func labelIdentity(values []string) string {
129
- return strings.Join(values, "\x00")
139
+func sampleObservationKey(instrument string, scope metrix.HostScope, values []string) observationKey {
140
+ return observationKey{
141
+ Instrument: instrument,
142
+ ScopeKey: scope.ScopeKey,
143
+ Labels: labelIdentity(values),
144
+ }
145
}
146
132
-func labelIdentityFromObservationKey(key string) string {
133
- parts := strings.SplitN(key, "\x00", 2)
134
- if len(parts) < 2 {
135
- return ""
136
- }
137
- return parts[1]
147
+func labelIdentity(values []string) observationLabelIdentity {
148
+ var out observationLabelIdentity
149
+ copy(out[:], values)
150
+ return out
151
}
152
153
func labelValues(labels metrix.Labels) []string {
src/go/plugin/go.d/collector/azure_monitor/plan.go
+3
-2
@@ -12,14 +12,15 @@ import (
12
"gopkg.in/yaml.v3"
13
)
14
15
-func buildCollectorRuntimeFromConfig(profileNames []string, profileEntries map[string]ProfileEntryConfig, catalog azureprofiles.Catalog) (*collectorRuntime, error) {
15
+func buildCollectorRuntimeFromConfig(profileNames []string, profileEntries map[string]ProfileEntryConfig, catalog azureprofiles.Catalog, workloadResourceTagKey string) (*collectorRuntime, error) {
16
profiles, err := catalog.Resolve(profileNames)
17
if err != nil {
18
return nil, err
19
}
20
21
runtime := &collectorRuntime{
22
- Profiles: make([]*profileRuntime, 0, len(profiles)),
22
+ Profiles: make([]*profileRuntime, 0, len(profiles)),
23
+ WorkloadResourceTagKey: stringsLowerTrim(workloadResourceTagKey),
24
}
25
26
seenProfileNames := make(map[string]struct{}, len(profiles))
src/go/plugin/go.d/collector/azure_monitor/query_executor.go
+3
-2
@@ -155,12 +155,12 @@ func samplesFromQueryResponse(metricData []azmetrics.MetricData, profileName str
155
if !ok {
156
continue
157
}
158
- samples = append(samples, samplesFromMetricValues(data.Values, resourceLabels(resource, profileName), metricToRuntime)...)
158
+ samples = append(samples, samplesFromMetricValues(data.Values, resource.HostScope, resourceLabels(resource, profileName), metricToRuntime)...)
159
}
160
return samples
161
}
162
163
-func samplesFromMetricValues(metrics []azmetrics.Metric, labels metrix.Labels, metricToRuntime map[string]*metricRuntime) []metricSample {
163
+func samplesFromMetricValues(metrics []azmetrics.Metric, scope metrix.HostScope, labels metrix.Labels, metricToRuntime map[string]*metricRuntime) []metricSample {
164
samples := make([]metricSample, 0, len(metrics))
165
for _, metric := range metrics {
166
runtimeMetric, ok := metricToRuntime[stringsLowerTrim(derefOrZero(metric.Name.Value))]
@@ -176,6 +176,7 @@ func samplesFromMetricValues(metrics []azmetrics.Metric, labels metrix.Labels, m
176
Instrument: series.Instrument,
177
Kind: series.Kind,
178
Labels: labels,
179
+ Scope: scope,
180
Value: value,
181
})
182
}
src/go/plugin/go.d/collector/azure_monitor/testdata/config_workload.json
new
+44
@@ -0,0 +1,44 @@
1
+{
2
+ "update_every": 60,
3
+ "subscription_ids": [
4
+ "sub-1"
5
+ ],
6
+ "cloud": "public",
7
+ "discovery": {
8
+ "refresh_every": 300,
9
+ "mode": "filters",
10
+ "mode_filters": {
11
+ "resource_groups": [
12
+ "rg-a"
13
+ ]
14
+ }
15
+ },
16
+ "profiles": {
17
+ "mode": "exact",
18
+ "mode_exact": {
19
+ "entries": [
20
+ {
21
+ "name": "sql_managed_instance"
22
+ }
23
+ ]
24
+ }
25
+ },
26
+ "virtual_nodes": {
27
+ "by_resource_tag": "workload"
28
+ },
29
+ "query_offset": 180,
30
+ "timeout": 30,
31
+ "limits": {
32
+ "max_concurrency": 4,
33
+ "max_batch_resources": 50,
34
+ "max_metrics_per_query": 20
35
+ },
36
+ "auth": {
37
+ "mode": "service_principal",
38
+ "mode_service_principal": {
39
+ "tenant_id": "11111111-1111-1111-1111-111111111111",
40
+ "client_id": "22222222-2222-2222-2222-222222222222",
41
+ "client_secret": "secret"
42
+ }
43
+ }
44
+}
src/go/plugin/go.d/collector/azure_monitor/testdata/config_workload.yaml
new
+29
@@ -0,0 +1,29 @@
1
+update_every: 60
2
+subscription_ids:
3
+ - sub-1
4
+cloud: public
5
+discovery:
6
+ refresh_every: 300
7
+ mode: filters
8
+ mode_filters:
9
+ resource_groups:
10
+ - rg-a
11
+profiles:
12
+ mode: exact
13
+ mode_exact:
14
+ entries:
15
+ - name: sql_managed_instance
16
+virtual_nodes:
17
+ by_resource_tag: workload
18
+query_offset: 180
19
+timeout: 30
20
+limits:
21
+ max_concurrency: 4
22
+ max_batch_resources: 50
23
+ max_metrics_per_query: 20
24
+auth:
25
+ mode: service_principal
26
+ mode_service_principal:
27
+ tenant_id: 11111111-1111-1111-1111-111111111111
28
+ client_id: 22222222-2222-2222-2222-222222222222
29
+ client_secret: secret
src/go/plugin/go.d/collector/azure_monitor/workload_scope.go
new
+129
@@ -0,0 +1,129 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package azure_monitor
4
+
5
+import (
6
+ "fmt"
7
+ "slices"
8
+ "strconv"
9
+ "strings"
10
+
11
+ "github.com/google/uuid"
12
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
13
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
14
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
15
+)
16
+
17
+const (
18
+ azureWorkloadScopeLabelKey = "_vnode_type"
19
+ azureWorkloadScopeLabelValue = "azure_workload"
20
+ azureWorkloadGUIDPrefix = "azure_monitor:"
21
+)
22
+
23
+type workloadScopeReport struct {
24
+ unsafeValues map[string]int
25
+}
26
+
27
+func applyWorkloadHostScopes(resources []resourceInfo, runtime *collectorRuntime) workloadScopeReport {
28
+ if runtime == nil || runtime.WorkloadResourceTagKey == "" {
29
+ return workloadScopeReport{}
30
+ }
31
+
32
+ var report workloadScopeReport
33
+ for i := range resources {
34
+ scope, unsafeValue := workloadHostScope(resources[i], runtime.WorkloadResourceTagKey)
35
+ resources[i].HostScope = scope
36
+ if unsafeValue == "" {
37
+ continue
38
+ }
39
+ if report.unsafeValues == nil {
40
+ report.unsafeValues = make(map[string]int)
41
+ }
42
+ report.unsafeValues[unsafeValue]++
43
+ }
44
+ return report
45
+}
46
+
47
+func workloadHostScope(resource resourceInfo, tagKey string) (metrix.HostScope, string) {
48
+ value := workloadTagValue(resource.Tags, tagKey)
49
+ if value == "" {
50
+ return metrix.HostScope{}, ""
51
+ }
52
+
53
+ guid := uuid.NewSHA1(uuid.NameSpaceDNS, []byte(azureWorkloadGUIDPrefix+value)).String()
54
+ scope := metrix.HostScope{
55
+ ScopeKey: guid,
56
+ GUID: guid,
57
+ Hostname: value,
58
+ Labels: map[string]string{
59
+ azureWorkloadScopeLabelKey: azureWorkloadScopeLabelValue,
60
+ },
61
+ }
62
+ if _, err := chartemit.PrepareHostInfo(netdataapi.HostInfo{
63
+ GUID: scope.GUID,
64
+ Hostname: scope.Hostname,
65
+ Labels: scope.Labels,
66
+ }); err != nil {
67
+ return metrix.HostScope{}, value
68
+ }
69
+ return scope, ""
70
+}
71
+
72
+func workloadTagValue(tags []resourceTag, tagKey string) string {
73
+ tagKey = stringsLowerTrim(tagKey)
74
+ if tagKey == "" {
75
+ return ""
76
+ }
77
+ for _, tag := range tags {
78
+ if tag.Key != tagKey {
79
+ continue
80
+ }
81
+ return stringsTrim(tag.Value)
82
+ }
83
+ return ""
84
+}
85
+
86
+func (c *Collector) warnDiscoveryScopeFallbacks(state discoveryState, runtime *collectorRuntime) {
87
+ if runtime == nil || runtime.WorkloadResourceTagKey == "" || state.FetchCounter == 0 {
88
+ return
89
+ }
90
+
91
+ if state.QueryTagsColumnMissing && (!c.tagsColumnMissingWarned || c.tagsColumnMissingWarnedAt != state.FetchCounter) {
92
+ c.tagsColumnMissingWarned = true
93
+ c.tagsColumnMissingWarnedAt = state.FetchCounter
94
+ c.Warningf(
95
+ "virtual_nodes.by_resource_tag %q is enabled, but the custom discovery query did not return a tags column; resources from that query will use the default host scope",
96
+ runtime.WorkloadResourceTagKey,
97
+ )
98
+ }
99
+ if state.QueryTagsWrongShape && (!c.tagsWrongShapeWarned || c.tagsWrongShapeWarnedAt != state.FetchCounter) {
100
+ c.tagsWrongShapeWarned = true
101
+ c.tagsWrongShapeWarnedAt = state.FetchCounter
102
+ c.Warningf(
103
+ "virtual_nodes.by_resource_tag %q is enabled, but the custom discovery query returned non-object tags; affected resources will use the default host scope",
104
+ runtime.WorkloadResourceTagKey,
105
+ )
106
+ }
107
+ if len(state.UnsafeWorkloadValues) == 0 {
108
+ return
109
+ }
110
+ c.Warningf(
111
+ "virtual_nodes.by_resource_tag %q ignored unsafe tag values; affected resources will use the default host scope: %s",
112
+ runtime.WorkloadResourceTagKey,
113
+ formatUnsafeWorkloadValues(state.UnsafeWorkloadValues),
114
+ )
115
+}
116
+
117
+func formatUnsafeWorkloadValues(values map[string]int) string {
118
+ keys := make([]string, 0, len(values))
119
+ for value := range values {
120
+ keys = append(keys, value)
121
+ }
122
+ slices.Sort(keys)
123
+
124
+ parts := make([]string, 0, len(keys))
125
+ for _, value := range keys {
126
+ parts = append(parts, fmt.Sprintf("%s (%d resources)", strconv.Quote(value), values[value]))
127
+ }
128
+ return strings.Join(parts, ", ")
129
+}
src/go/plugin/go.d/config/go.d/azure_monitor.conf
+135
-26
@@ -4,21 +4,117 @@
4
## Profile files:
5
## stock: /usr/lib/netdata/conf.d/go.d/azure_monitor.profiles/default/
6
## user: /etc/netdata/go.d/azure_monitor.profiles/
7
-## User profile files override stock files when profile `id` matches.
8
-## Filenames are packaging only; matching filename == profile id is recommended.
9
-## The default profile catalog is loaded once per go.d process and cached after the first successful load.
10
-## Changes to profile files under those default dirs require a go.d process restart to take effect.
7
+## User profile files override stock files when the profile basename matches.
8
+## The default profile catalog is loaded once per go.d process and cached after
9
+## the first successful load. Changes under the default profile directories
10
+## require a go.d process restart.
11
+##
12
+## Discovery modes:
13
+## filters - Netdata builds an Azure Resource Graph query from structured
14
+## filters and always projects tags.
15
+## query - you provide Azure Resource Graph KQL. The result must project
16
+## id, name, type, resourceGroup, and location. Project tags too
17
+## when using virtual_nodes.by_resource_tag.
18
##
19
## Profile selection modes:
13
-## auto - discovers resource types via Azure Resource Graph and enables matching profiles (default).
14
-## exact - uses only the explicitly listed profile ids.
15
-## combined - merges explicitly listed profile ids with auto-discovered profiles.
20
+## auto - discovers resource types and enables matching profiles.
21
+## exact - uses only explicitly listed profile entries.
22
+## combined - merges explicitly listed profile entries with auto-discovered
23
+## profiles.
24
25
#jobs:
18
-# - name: example_auto
19
-# subscription_id: "<subscription-id>"
20
-# timeout: 30
21
-# profile_selection_mode: auto
26
+# - name: service_principal_filters_auto
27
+# subscription_ids:
28
+# - "<subscription-id>"
29
+# discovery:
30
+# mode: filters
31
+# mode_filters:
32
+# resource_groups:
33
+# - production-rg
34
+# regions:
35
+# - eastus
36
+# tags:
37
+# env:
38
+# - prod
39
+# profiles:
40
+# mode: auto
41
+# auth:
42
+# mode: service_principal
43
+# mode_service_principal:
44
+# tenant_id: "<tenant-id>"
45
+# client_id: "<client-id>"
46
+# client_secret: "<client-secret>"
47
+#
48
+# - name: service_principal_workload_vnodes
49
+# subscription_ids:
50
+# - "<subscription-id>"
51
+# virtual_nodes:
52
+# by_resource_tag: workload
53
+# discovery:
54
+# mode: filters
55
+# profiles:
56
+# mode: auto
57
+# auth:
58
+# mode: service_principal
59
+# mode_service_principal:
60
+# tenant_id: "<tenant-id>"
61
+# client_id: "<client-id>"
62
+# client_secret: "<client-secret>"
63
+#
64
+# - name: service_principal_query_workload_vnodes
65
+# subscription_ids:
66
+# - "<subscription-id>"
67
+# virtual_nodes:
68
+# by_resource_tag: workload
69
+# discovery:
70
+# mode: query
71
+# mode_query:
72
+# kql: |
73
+# resources
74
+# | where tags.env == 'prod'
75
+# | project id, name, type, resourceGroup, location, tags
76
+# profiles:
77
+# mode: auto
78
+# auth:
79
+# mode: service_principal
80
+# mode_service_principal:
81
+# tenant_id: "<tenant-id>"
82
+# client_id: "<client-id>"
83
+# client_secret: "<client-secret>"
84
+#
85
+# - name: managed_identity_exact
86
+# subscription_ids:
87
+# - "<subscription-id>"
88
+# profiles:
89
+# mode: exact
90
+# mode_exact:
91
+# entries:
92
+# - name: postgres_flexible
93
+# - name: cosmos_db
94
+# auth:
95
+# mode: managed_identity
96
+# mode_managed_identity:
97
+# client_id: "<optional-user-assigned-managed-identity-client-id>"
98
+#
99
+# - name: default_credential_combined
100
+# subscription_ids:
101
+# - "<subscription-id>"
102
+# profiles:
103
+# mode: combined
104
+# mode_combined:
105
+# entries:
106
+# - name: sql_database
107
+# auth:
108
+# mode: default
109
+#
110
+# - name: multiple_subscriptions
111
+# subscription_ids:
112
+# - "<subscription-id-a>"
113
+# - "<subscription-id-b>"
114
+# discovery:
115
+# mode: filters
116
+# profiles:
117
+# mode: auto
118
# auth:
119
# mode: service_principal
120
# mode_service_principal:
@@ -26,14 +122,24 @@
122
# client_id: "<client-id>"
123
# client_secret: "<client-secret>"
124
#
29
-# - name: example_exact
30
-# subscription_id: "<subscription-id>"
31
-# timeout: 30
32
-# profile_selection_mode: exact
33
-# profile_selection_mode_exact:
34
-# profiles:
35
-# - postgres_flexible
36
-# - cosmos_db
125
+# - name: profile_level_filters
126
+# subscription_ids:
127
+# - "<subscription-id>"
128
+# discovery:
129
+# mode: filters
130
+# profiles:
131
+# mode: exact
132
+# mode_exact:
133
+# entries:
134
+# - name: postgres_flexible
135
+# filters:
136
+# resource_groups:
137
+# - database-rg
138
+# regions:
139
+# - eastus
140
+# tags:
141
+# role:
142
+# - primary
143
# auth:
144
# mode: service_principal
145
# mode_service_principal:
@@ -41,13 +147,16 @@
147
# client_id: "<client-id>"
148
# client_secret: "<client-secret>"
149
#
44
-# - name: example_combined
45
-# subscription_id: "<subscription-id>"
46
-# timeout: 30
47
-# profile_selection_mode: combined
48
-# profile_selection_mode_combined:
49
-# profiles:
50
-# - custom_profile
150
+# - name: job_vnode_with_workload_vnodes
151
+# vnode: azure-fallback-node
152
+# subscription_ids:
153
+# - "<subscription-id>"
154
+# virtual_nodes:
155
+# by_resource_tag: workload
156
+# discovery:
157
+# mode: filters
158
+# profiles:
159
+# mode: auto
160
# auth:
161
# mode: service_principal
162
# mode_service_principal: