diff --git a/embed/host.go b/embed/host.go index 5f202b6..5914776 100644 --- a/embed/host.go +++ b/embed/host.go @@ -34,7 +34,7 @@ type Host struct { // runtime-owned copy that no caller can race with; callers must still // treat their own reference as frozen once they hand the Host off. // See cloneResource in this package. - Resource map[string]string + Resource map[string]any // Environment is the simulated datagen.Environment that generators draw // their host identities (host.name / os.type) from (PIPE-1036). Nil means @@ -56,11 +56,11 @@ type Host struct { // worker goroutines reading the per-session resource attributes race // neither with each other nor with a caller that retains a reference // and later mutates its own copy. -func cloneResource(m map[string]string) map[string]string { +func cloneResource(m map[string]any) map[string]any { if m == nil { return nil } - out := make(map[string]string, len(m)) + out := make(map[string]any, len(m)) for k, v := range m { out[k] = v } diff --git a/embed/new.go b/embed/new.go index db76cc2..996f63a 100644 --- a/embed/new.go +++ b/embed/new.go @@ -35,7 +35,7 @@ type runner struct { mu sync.Mutex rt *runtime.Runtime - resource map[string]string // cloned from host.Resource at Start + resource map[string]any // cloned from host.Resource at Start started bool } diff --git a/embed/record.go b/embed/record.go index 7271035..ee86e5e 100644 --- a/embed/record.go +++ b/embed/record.go @@ -38,7 +38,7 @@ type LogRecordMetadata struct { Timestamp time.Time Severity string Attributes map[string]any - Resource map[string]string + Resource map[string]any } // MetricType represents the type of a metric data point. @@ -101,7 +101,7 @@ type MetricPoint struct { type MetricPointMetadata struct { Timestamp time.Time Attributes map[string]string - Resource map[string]string + Resource map[string]any } // SpanKind represents the kind of a trace span. @@ -156,5 +156,5 @@ type Span struct { // arrays). type SpanMetadata struct { Attributes map[string]any - Resource map[string]string + Resource map[string]any } diff --git a/embed/record_test.go b/embed/record_test.go index 4c40561..8d1b4bd 100644 --- a/embed/record_test.go +++ b/embed/record_test.go @@ -28,7 +28,7 @@ func TestLogRecordMetadataCarriesResourceAndAttributes(t *testing.T) { Message: "GET / 200", Metadata: embed.LogRecordMetadata{ Severity: "INFO", - Resource: map[string]string{ + Resource: map[string]any{ "host.name": "web-01", "telemetry.source": "apache", "apache.format": "common", diff --git a/generator/fix/fix_test.go b/generator/fix/fix_test.go index a89d6ae..6429283 100644 --- a/generator/fix/fix_test.go +++ b/generator/fix/fix_test.go @@ -19,7 +19,7 @@ import ( type captureConsumer struct { mu sync.Mutex got [][]byte - resources []map[string]string + resources []map[string]any } func (c *captureConsumer) ConsumeLogs(_ context.Context, records []embed.LogRecord) error { @@ -42,7 +42,7 @@ func (c *captureConsumer) Snapshot() [][]byte { return out } -func (c *captureConsumer) ResourceAt(i int) map[string]string { +func (c *captureConsumer) ResourceAt(i int) map[string]any { c.mu.Lock() defer c.mu.Unlock() if i < 0 || i >= len(c.resources) { diff --git a/generator/hostmetrics/cpu.go b/generator/hostmetrics/cpu.go index ac0fc7b..18ee38c 100644 --- a/generator/hostmetrics/cpu.go +++ b/generator/hostmetrics/cpu.go @@ -11,7 +11,7 @@ type cpuScraper struct{} func (s *cpuScraper) Name() string { return "cpu" } -func (s *cpuScraper) Scrape(r *rand.Rand, hostname string, resource map[string]string) []output.MetricRecord { +func (s *cpuScraper) Scrape(r *rand.Rand, hostname string, resource map[string]any) []output.MetricRecord { now := time.Now() numCPUs := r.Intn(16) + 1 // #nosec G404 diff --git a/generator/hostmetrics/disk.go b/generator/hostmetrics/disk.go index e00216d..c95e384 100644 --- a/generator/hostmetrics/disk.go +++ b/generator/hostmetrics/disk.go @@ -11,7 +11,7 @@ type diskScraper struct{} func (s *diskScraper) Name() string { return "disk" } -func (s *diskScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *diskScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() devices := []string{"sda", "sdb", "nvme0n1"} device := devices[r.Intn(len(devices))] // #nosec G404 diff --git a/generator/hostmetrics/filesystem.go b/generator/hostmetrics/filesystem.go index b439030..9f4b5f6 100644 --- a/generator/hostmetrics/filesystem.go +++ b/generator/hostmetrics/filesystem.go @@ -11,7 +11,7 @@ type filesystemScraper struct{} func (s *filesystemScraper) Name() string { return "filesystem" } -func (s *filesystemScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *filesystemScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() type mount struct { diff --git a/generator/hostmetrics/hostmetrics_test.go b/generator/hostmetrics/hostmetrics_test.go index b0ca4a3..8109541 100644 --- a/generator/hostmetrics/hostmetrics_test.go +++ b/generator/hostmetrics/hostmetrics_test.go @@ -200,7 +200,7 @@ func TestSeedDeterminism(t *testing.T) { // Test individual scrapers produce non-empty records. func TestScrapers(t *testing.T) { r := rand.New(rand.NewSource(42)) // #nosec G404 - resource := map[string]string{"host.name": "test", "os.type": "linux"} + resource := map[string]any{"host.name": "test", "os.type": "linux"} scrapers := allScrapers() for _, s := range scrapers { diff --git a/generator/hostmetrics/load.go b/generator/hostmetrics/load.go index e58ae1e..8e56067 100644 --- a/generator/hostmetrics/load.go +++ b/generator/hostmetrics/load.go @@ -11,7 +11,7 @@ type loadScraper struct{} func (s *loadScraper) Name() string { return "load" } -func (s *loadScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *loadScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() // Load averages: 1m > 5m > 15m (typical pattern) diff --git a/generator/hostmetrics/memory.go b/generator/hostmetrics/memory.go index b737c72..082ea93 100644 --- a/generator/hostmetrics/memory.go +++ b/generator/hostmetrics/memory.go @@ -11,7 +11,7 @@ type memoryScraper struct{} func (s *memoryScraper) Name() string { return "memory" } -func (s *memoryScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *memoryScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() totalGB := []int64{4, 8, 16, 32, 64} total := totalGB[r.Intn(len(totalGB))] * 1024 * 1024 * 1024 // #nosec G404 diff --git a/generator/hostmetrics/network.go b/generator/hostmetrics/network.go index 75ea1a5..1207876 100644 --- a/generator/hostmetrics/network.go +++ b/generator/hostmetrics/network.go @@ -11,7 +11,7 @@ type networkScraper struct{} func (s *networkScraper) Name() string { return "network" } -func (s *networkScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *networkScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() ifaces := []string{"eth0", "eth1", "lo", "ens192", "bond0"} iface := ifaces[r.Intn(len(ifaces))] // #nosec G404 diff --git a/generator/hostmetrics/paging.go b/generator/hostmetrics/paging.go index 2ed9bda..9cd9756 100644 --- a/generator/hostmetrics/paging.go +++ b/generator/hostmetrics/paging.go @@ -11,7 +11,7 @@ type pagingScraper struct{} func (s *pagingScraper) Name() string { return "paging" } -func (s *pagingScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *pagingScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() swapTotal := int64(r.Intn(8)+1) * 1024 * 1024 * 1024 // 1-8 GB #nosec G404 diff --git a/generator/hostmetrics/processes.go b/generator/hostmetrics/processes.go index 9ed4ba0..760c159 100644 --- a/generator/hostmetrics/processes.go +++ b/generator/hostmetrics/processes.go @@ -11,7 +11,7 @@ type processesScraper struct{} func (s *processesScraper) Name() string { return "processes" } -func (s *processesScraper) Scrape(r *rand.Rand, _ string, resource map[string]string) []output.MetricRecord { +func (s *processesScraper) Scrape(r *rand.Rand, _ string, resource map[string]any) []output.MetricRecord { now := time.Now() running := int64(r.Intn(20)) + 1 // #nosec G404 diff --git a/generator/hostmetrics/scraper.go b/generator/hostmetrics/scraper.go index d5de7b5..c16182a 100644 --- a/generator/hostmetrics/scraper.go +++ b/generator/hostmetrics/scraper.go @@ -11,5 +11,5 @@ type Scraper interface { // Name returns the scraper name. Name() string // Scrape generates metric records for the current scrape cycle. - Scrape(r *rand.Rand, hostname string, resource map[string]string) []output.MetricRecord + Scrape(r *rand.Rand, hostname string, resource map[string]any) []output.MetricRecord } diff --git a/generator/resource/resource.go b/generator/resource/resource.go index e42ba89..438fece 100644 --- a/generator/resource/resource.go +++ b/generator/resource/resource.go @@ -50,7 +50,7 @@ func Hostname() string { // // resource.Default("apache", "apache.format", "common") // // → {"host.name": "", "telemetry.source": "apache", "apache.format": "common"} -func Default(source string, extras ...string) map[string]string { +func Default(source string, extras ...string) map[string]any { return WithHost(Hostname(), source, extras...) } @@ -58,8 +58,8 @@ func Default(source string, extras ...string) map[string]string { // for generators whose hostname comes from a resolved datagen SystemIdentity // (PIPE-1036) rather than the process's os.Hostname(). extras follow the same // key/value convention as Default. -func WithHost(hostname, source string, extras ...string) map[string]string { - r := map[string]string{ +func WithHost(hostname, source string, extras ...string) map[string]any { + r := map[string]any{ "host.name": hostname, "telemetry.source": source, } @@ -78,14 +78,14 @@ func WithHost(hostname, source string, extras ...string) map[string]string { // The model is: Static + Dynamic (per record) = Record. Static carries the // fields that never change for the worker; Record merges in the few that do. type StaticResources struct { - attrs map[string]string + attrs map[string]any } // NewStaticResources builds a StaticResources from a base attribute set. The // map is copied, so a caller that retains and later mutates attrs does not // affect the constructed value. -func NewStaticResources(attrs map[string]string) *StaticResources { - cp := make(map[string]string, len(attrs)) +func NewStaticResources(attrs map[string]any) *StaticResources { + cp := make(map[string]any, len(attrs)) for k, v := range attrs { cp[k] = v } @@ -102,11 +102,11 @@ func NewStaticResources(attrs map[string]string) *StaticResources { // corrupts every other record and races concurrent workers. When dynamic pairs // are supplied, Record returns a fresh merged map that is safe to mutate and // leaves the static set untouched. -func (s *StaticResources) Record(dynamicKV ...string) map[string]string { +func (s *StaticResources) Record(dynamicKV ...string) map[string]any { if len(dynamicKV) < 2 { return s.attrs } - out := make(map[string]string, len(s.attrs)+len(dynamicKV)/2) + out := make(map[string]any, len(s.attrs)+len(dynamicKV)/2) for k, v := range s.attrs { out[k] = v } diff --git a/generator/resource/resource_static_test.go b/generator/resource/resource_static_test.go index a19bf2d..92adfe4 100644 --- a/generator/resource/resource_static_test.go +++ b/generator/resource/resource_static_test.go @@ -5,10 +5,10 @@ import ( "testing" ) -func mapPtr(m map[string]string) uintptr { return reflect.ValueOf(m).Pointer() } +func mapPtr(m map[string]any) uintptr { return reflect.ValueOf(m).Pointer() } func TestStaticResourcesConstructorCopies(t *testing.T) { - base := map[string]string{"host.name": "thor-web-01", "telemetry.source": "apache"} + base := map[string]any{"host.name": "thor-web-01", "telemetry.source": "apache"} s := NewStaticResources(base) // Mutating the input after construction must not leak into the static set. @@ -25,7 +25,7 @@ func TestStaticResourcesConstructorCopies(t *testing.T) { } func TestStaticResourcesRecordNoDynamicIsSharedAndReadOnly(t *testing.T) { - s := NewStaticResources(map[string]string{"host.name": "thor-web-01", "telemetry.source": "apache"}) + s := NewStaticResources(map[string]any{"host.name": "thor-web-01", "telemetry.source": "apache"}) a := s.Record() b := s.Record() @@ -40,7 +40,7 @@ func TestStaticResourcesRecordNoDynamicIsSharedAndReadOnly(t *testing.T) { } func TestStaticResourcesRecordWithDynamicMergesWithoutMutatingStatic(t *testing.T) { - s := NewStaticResources(map[string]string{"host.name": "thor-web-01", "telemetry.source": "wel"}) + s := NewStaticResources(map[string]any{"host.name": "thor-web-01", "telemetry.source": "wel"}) rec := s.Record("wel.channel", "Security", "wel.role", "dc") @@ -59,7 +59,7 @@ func TestStaticResourcesRecordWithDynamicMergesWithoutMutatingStatic(t *testing. } func TestStaticResourcesRecordOddArgsIgnoresTrailing(t *testing.T) { - s := NewStaticResources(map[string]string{"telemetry.source": "json"}) + s := NewStaticResources(map[string]any{"telemetry.source": "json"}) // A single unpaired arg is treated as "no complete dynamic pair": shared static. if mapPtr(s.Record("dangling")) != mapPtr(s.Record()) { diff --git a/generator/traces/traces.go b/generator/traces/traces.go index ed64582..c9957df 100644 --- a/generator/traces/traces.go +++ b/generator/traces/traces.go @@ -340,7 +340,7 @@ func (g *Generator) startTrace(r *mathrand.Rand) { } } -func (g *Generator) buildChildSpan(r *mathrand.Rand, kind childKind, traceID, parentID string, earliestStart time.Time, res map[string]string) embed.Span { +func (g *Generator) buildChildSpan(r *mathrand.Rand, kind childKind, traceID, parentID string, earliestStart time.Time, res map[string]any) embed.Span { spanID := generateSpanID() startOffset := time.Duration(r.Intn(50)) * time.Millisecond // #nosec G404 start := earliestStart.Add(startOffset) @@ -447,8 +447,8 @@ func (g *Generator) emitSpan(sp embed.Span) { // cloneResource returns a defensive copy so per-span mutations (e.g. a // future host-base merge in the runner) can't bleed across spans. -func cloneResource(src map[string]string) map[string]string { - out := make(map[string]string, len(src)) +func cloneResource(src map[string]any) map[string]any { + out := make(map[string]any, len(src)) for k, v := range src { out[k] = v } diff --git a/output/otlp_grpc/metrics_traces.go b/output/otlp_grpc/metrics_traces.go index 53b94e7..272383b 100644 --- a/output/otlp_grpc/metrics_traces.go +++ b/output/otlp_grpc/metrics_traces.go @@ -195,6 +195,13 @@ func toAnyValueSimple(v any) *commonpb.AnyValue { return &commonpb.AnyValue{Value: &commonpb.AnyValue_DoubleValue{DoubleValue: x}} case bool: return &commonpb.AnyValue{Value: &commonpb.AnyValue_BoolValue{BoolValue: x}} + case []string: + // Homogeneous string array (e.g. host.ip / host.mac) → OTLP ArrayValue. + values := make([]*commonpb.AnyValue, 0, len(x)) + for _, s := range x { + values = append(values, &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: s}}) + } + return &commonpb.AnyValue{Value: &commonpb.AnyValue_ArrayValue{ArrayValue: &commonpb.ArrayValue{Values: values}}} default: return &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: fmt.Sprintf("%v", x)}} } @@ -224,18 +231,13 @@ func hexByte(c byte) byte { } // buildMetricRequest builds an OTLP ExportMetricsServiceRequest from prepared metrics. -func buildMetricRequest(metrics []*metricspb.Metric, resource map[string]string) *metricspb.ResourceMetrics { +func buildMetricRequest(metrics []*metricspb.Metric, resource map[string]any) *metricspb.ResourceMetrics { resourceAttrs := make([]*commonpb.KeyValue, 0, len(resource)+1) resourceAttrs = append(resourceAttrs, &commonpb.KeyValue{ Key: "service.name", Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: "blitz"}}, }) - for k, v := range resource { - resourceAttrs = append(resourceAttrs, &commonpb.KeyValue{ - Key: k, - Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: v}}, - }) - } + resourceAttrs = append(resourceAttrs, anyMapToKeyValues(resource)...) return &metricspb.ResourceMetrics{ Resource: &resourcepb.Resource{ diff --git a/output/otlp_grpc/resource_array_test.go b/output/otlp_grpc/resource_array_test.go new file mode 100644 index 0000000..7858c8e --- /dev/null +++ b/output/otlp_grpc/resource_array_test.go @@ -0,0 +1,45 @@ +package otlpgrpc + +import ( + "testing" + + commonpb "go.opentelemetry.io/proto/otlp/common/v1" +) + +// A []string resource attribute (e.g. host.ip / host.mac) must serialize to an +// OTLP ArrayValue of StringValues, not a stringified slice (PIPE-1253). +func TestBuildMetricRequest_ArrayResourceAttribute(t *testing.T) { + rm := buildMetricRequest(nil, map[string]any{ + "host.ip": []string{"10.0.0.1", "10.0.0.2"}, + }) + + var hostIP *commonpb.AnyValue + for _, kv := range rm.Resource.Attributes { + if kv.Key == "host.ip" { + hostIP = kv.Value + } + } + if hostIP == nil { + t.Fatal("host.ip resource attribute not found") + } + + arr, ok := hostIP.Value.(*commonpb.AnyValue_ArrayValue) + if !ok { + t.Fatalf("host.ip should serialize as an ArrayValue, got %T", hostIP.Value) + } + if len(arr.ArrayValue.Values) != 2 { + t.Fatalf("host.ip array should have 2 elements, got %d", len(arr.ArrayValue.Values)) + } + + got := make([]string, 0, 2) + for _, v := range arr.ArrayValue.Values { + sv, ok := v.Value.(*commonpb.AnyValue_StringValue) + if !ok { + t.Fatalf("array element should be a StringValue, got %T", v.Value) + } + got = append(got, sv.StringValue) + } + if got[0] != "10.0.0.1" || got[1] != "10.0.0.2" { + t.Errorf("array elements = %v, want [10.0.0.1 10.0.0.2]", got) + } +}