mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 11:46:37 +00:00
feat: mirror the guest account onto the trace account field (#825)
Guest series from qmp-collector carry the owning account as the CloudWatch dimension account_id. Traces and logs carry the same account as aws.account_id, and on metrics-apm* the bare account_id name is already taken by the NATS global account the nats service reports. A dashboard control built on the trace field therefore cannot reach guest metrics, which is why the guest panels sit apart from the account drill-down. The dimension name is an AWS-facing contract, so it is not renamed. The OTel bridge mirrors it instead: guest series now export aws.account_id alongside account_id, and a series with no account exports neither a blank value nor a placeholder.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
|
||||
const bridgeMeterName = "github.com/mulgadc/spinifex/spinifex/services/qmpcollector"
|
||||
|
||||
// cloudwatchAccountDimension is the dimension name guest series carry. It is
|
||||
// the AWS-facing contract, so it is read here rather than renamed.
|
||||
const cloudwatchAccountDimension = "account_id"
|
||||
|
||||
// bridgeQueueGroup makes exactly one node's bridge forward each series: NATS
|
||||
// is clustered, so a plain metrics.> subscription on every node would ship
|
||||
// duplicates to the sink.
|
||||
@@ -68,13 +73,19 @@ func (b *bridge) handle(msg *nats.Msg) {
|
||||
expires := time.Now().Add(ttl)
|
||||
|
||||
for _, s := range batch.Series {
|
||||
attrs := make([]attribute.KeyValue, 0, len(s.Labels)+1)
|
||||
attrs := make([]attribute.KeyValue, 0, len(s.Labels)+2)
|
||||
for k, v := range s.Labels {
|
||||
attrs = append(attrs, attribute.String(k, v))
|
||||
}
|
||||
if batch.Node != "" {
|
||||
attrs = append(attrs, attribute.String("node", batch.Node))
|
||||
}
|
||||
// account_id is the CloudWatch dimension and stays as it is. Mirror it
|
||||
// under the name traces use, so one account field spans both and guest
|
||||
// metrics answer to the same filter as every other signal.
|
||||
if account := s.Labels[cloudwatchAccountDimension]; account != "" {
|
||||
attrs = append(attrs, attribute.String(utils.AttrAccountID, account))
|
||||
}
|
||||
b.observe(s.Name, labelKey(s.Name, s.Labels), bridgeEntry{
|
||||
value: s.Value,
|
||||
attrs: attribute.NewSet(attrs...),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package qmpcollector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
// newTestBridge builds a bridge without a NATS subscription, so handle can be
|
||||
// driven with a synthetic batch.
|
||||
func newTestBridge() *bridge {
|
||||
return &bridge{
|
||||
meter: otel.Meter(bridgeMeterName),
|
||||
series: make(map[string]map[string]bridgeEntry),
|
||||
gauges: make(map[string]metric.Float64ObservableGauge),
|
||||
}
|
||||
}
|
||||
|
||||
// handleOne feeds one series through the bridge and returns the attributes it
|
||||
// recorded for it.
|
||||
func handleOne(t *testing.T, labels map[string]string) attribute.Set {
|
||||
t.Helper()
|
||||
b := newTestBridge()
|
||||
batch := types.TelemetryBatch{
|
||||
PeriodSeconds: 60,
|
||||
Node: "node-1",
|
||||
Series: []types.TelemetrySeries{{Name: "goanna_ec2_cpu_utilization", Labels: labels, Value: 12}},
|
||||
}
|
||||
data, err := json.Marshal(batch)
|
||||
require.NoError(t, err)
|
||||
b.handle(&nats.Msg{Subject: types.MetricsEC2SubjectPrefix + "i-test", Data: data})
|
||||
|
||||
entries := b.series["goanna_ec2_cpu_utilization"]
|
||||
require.Len(t, entries, 1, "one series in, one series stored")
|
||||
for _, e := range entries {
|
||||
return e.attrs
|
||||
}
|
||||
return attribute.NewSet()
|
||||
}
|
||||
|
||||
// attrValue returns the value of key in set, or "".
|
||||
func attrValue(set attribute.Set, key string) string {
|
||||
v, ok := set.Value(attribute.Key(key))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return v.AsString()
|
||||
}
|
||||
|
||||
// Guest metrics carry account_id as a CloudWatch dimension. Traces name the
|
||||
// same account aws.account_id, so the bridge mirrors it: one field answers for
|
||||
// both signals, and the CloudWatch dimension is left as the contract it is.
|
||||
func TestBridgeMirrorsTheAccountDimension(t *testing.T) {
|
||||
attrs := handleOne(t, map[string]string{
|
||||
"instance_id": "i-test",
|
||||
"account_id": "000000000042",
|
||||
"namespace": "AWS/EC2",
|
||||
})
|
||||
|
||||
assert.Equal(t, "000000000042", attrValue(attrs, cloudwatchAccountDimension),
|
||||
"the CloudWatch dimension must survive unchanged")
|
||||
assert.Equal(t, "000000000042", attrValue(attrs, utils.AttrAccountID),
|
||||
"the account must also appear under the field traces use")
|
||||
assert.Equal(t, "node-1", attrValue(attrs, "node"))
|
||||
}
|
||||
|
||||
// A series with no account must not gain a blank one: an empty account reads
|
||||
// as an account in Kibana and would collect every unattributed guest.
|
||||
func TestBridgeOmitsAnAbsentAccount(t *testing.T) {
|
||||
attrs := handleOne(t, map[string]string{"instance_id": "i-test"})
|
||||
|
||||
assert.Empty(t, attrValue(attrs, utils.AttrAccountID),
|
||||
"an unattributed series must carry no account attribute")
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func buildBatch(meta types.GuestTelemetryMeta, node string, prev, cur *sample) (
|
||||
"instance_id": meta.InstanceID,
|
||||
}
|
||||
if meta.AccountID != "" {
|
||||
labels["account_id"] = meta.AccountID
|
||||
labels[cloudwatchAccountDimension] = meta.AccountID
|
||||
}
|
||||
|
||||
vcpus := max(meta.VCPUs, 1)
|
||||
|
||||
Reference in New Issue
Block a user