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:
Josh Siviour
2026-08-18 13:57:45 +10:00
committed by GitHub
parent c84081fbd5
commit ba46ced923
3 changed files with 95 additions and 2 deletions
+12 -1
View File
@@ -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")
}
+1 -1
View File
@@ -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)