diff --git a/.chloggen/prometheusreceiver_scope_attributes_spec_compliance.yaml b/.chloggen/prometheusreceiver_scope_attributes_spec_compliance.yaml new file mode 100644 index 0000000000000..cfa631ea2aed3 --- /dev/null +++ b/.chloggen/prometheusreceiver_scope_attributes_spec_compliance.yaml @@ -0,0 +1,36 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: prometheusreceiver + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Implement scope attributes extraction from `otel_scope_` prefixed labels to comply with updated OpenTelemetry specification" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [40060] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + The OpenTelemetry Prometheus specification has been updated to deprecate the `otel_scope_info` metric + in favor of embedding scope attributes directly in metrics using `otel_scope_` prefixed labels. + This implementation always extracts scope attributes from `otel_scope_` prefixed labels on all metrics + and always filters these labels from datapoint attributes, regardless of the feature gate setting. + The feature gate `receiver.prometheusreceiver.RemoveScopeInfo` only controls `otel_scope_info` processing: + - When disabled: `otel_scope_info` metrics are processed for scope attributes and merged with `otel_scope_` labels. + - When enabled: `otel_scope_info` metrics are treated as regular metrics. + This change maintains backward compatibility while enabling compliance with the updated specification. + See: https://github.com/open-telemetry/opentelemetry-specification/pull/4505 + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [user] diff --git a/receiver/prometheusreceiver/README.md b/receiver/prometheusreceiver/README.md index 928f8e89b23a6..6967011e4df9f 100644 --- a/receiver/prometheusreceiver/README.md +++ b/receiver/prometheusreceiver/README.md @@ -153,10 +153,16 @@ This receiver accepts exemplars coming in Prometheus format and converts it to O This receiver drops the `target_info` prometheus metric, if present, and uses attributes on that metric to populate the OpenTelemetry Resource. -It drops `otel_scope_name` and `otel_scope_version` labels, if present, from metrics, and uses them to populate -the OpenTelemetry Instrumentation Scope name and version. It drops the `otel_scope_info` metric, -and uses attributes (other than `otel_scope_name` and `otel_scope_version`) to populate Scope -Attributes. +It always extracts scope attributes from `otel_scope_` prefixed labels on metrics. Labels with the +`otel_scope_name`, `otel_scope_version`, and `otel_scope_schema_url` prefixes are used to populate the +OpenTelemetry Instrumentation Scope identity, while other `otel_scope_` prefixed labels become Scope Attributes. +All `otel_scope_` labels are filtered from the metric datapoint attributes. + +The processing of `otel_scope_info` metrics is controlled by the `receiver.prometheusreceiver.RemoveScopeInfo` +feature gate: +- When disabled: `otel_scope_info` metrics are processed for scope attributes and merged with + any `otel_scope_` labels, with `otel_scope_` labels taking precedence in conflicts. +- When enabled: `otel_scope_info` metrics are treated as regular metrics and not processed for scope attributes. ## Prometheus API Server The Prometheus API server can be enabled to host info about the Prometheus targets, config, service discovery, and metrics. The `server_config` can be specified using the OpenTelemetry confighttp package. An example configuration would be: diff --git a/receiver/prometheusreceiver/internal/metricfamily.go b/receiver/prometheusreceiver/internal/metricfamily.go index b0cbe34937540..c5dfb636597b5 100644 --- a/receiver/prometheusreceiver/internal/metricfamily.go +++ b/receiver/prometheusreceiver/internal/metricfamily.go @@ -360,6 +360,11 @@ func populateAttributes(mType pmetric.MetricType, ls labels.Labels, dest pcommon if j < len(names) && l.Name == names[j] { return } + + if strings.HasPrefix(l.Name, "otel_scope_") { + return + } + if l.Value == "" { // empty label values should be omitted return diff --git a/receiver/prometheusreceiver/internal/metricfamily_test.go b/receiver/prometheusreceiver/internal/metricfamily_test.go index 3eafed50e703e..13b4dfdcc223f 100644 --- a/receiver/prometheusreceiver/internal/metricfamily_test.go +++ b/receiver/prometheusreceiver/internal/metricfamily_test.go @@ -5,6 +5,7 @@ package internal import ( "math" + "strings" "testing" "time" @@ -17,6 +18,8 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" "go.uber.org/zap" + + "github.com/open-telemetry/opentelemetry-collector-contrib/internal/common/testutil" ) type testMetadataStore map[string]scrape.MetricMetadata @@ -773,6 +776,77 @@ func TestMetricGroupData_toSummaryUnitTest(t *testing.T) { } } +// TestPopulateAttributes_ScopeLabelFiltering tests the target behavior where otel_scope_ +// prefixed labels should always be filtered from datapoint attributes regardless of feature gate. +func TestPopulateAttributes_ScopeLabelFiltering(t *testing.T) { + tests := []struct { + name string + labels labels.Labels + featureEnabled bool + wantAttrs map[string]string + }{ + { + name: "feature_gate_disabled", + labels: labels.FromStrings( + "__name__", "test_metric", + "job", "test-job", + "instance", "localhost:8080", + "method", "GET", + "otel_scope_name", "my-scope", + "otel_scope_version", "1.0.0", + "otel_scope_animal", "bear", + ), + featureEnabled: false, + wantAttrs: map[string]string{ + "method": "GET", + // otel_scope_* labels should always be filtered out regardless of feature gate + }, + }, + { + name: "feature_gate_enabled", + labels: labels.FromStrings( + "__name__", "test_metric", + "job", "test-job", + "instance", "localhost:8080", + "method", "GET", + "otel_scope_name", "my-scope", + "otel_scope_version", "1.0.0", + "otel_scope_animal", "bear", + ), + featureEnabled: true, + wantAttrs: map[string]string{ + "method": "GET", + // otel_scope_* labels should always be filtered out regardless of feature gate + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer testutil.SetFeatureGateForTest(t, RemoveScopeInfoGate, tt.featureEnabled)() + + attrs := pcommon.NewMap() + populateAttributes(pmetric.MetricTypeGauge, tt.labels, attrs) + + // Verify expected attributes + require.Equal(t, len(tt.wantAttrs), attrs.Len(), "Unexpected number of attributes") + + for key, expectedValue := range tt.wantAttrs { + actualValue, exists := attrs.Get(key) + require.True(t, exists, "Expected attribute %s not found", key) + require.Equal(t, expectedValue, actualValue.AsString(), "Unexpected value for attribute %s", key) + } + + // Verify no otel_scope_ attributes are present regardless of feature gate + attrs.Range(func(k string, _ pcommon.Value) bool { + require.False(t, strings.HasPrefix(k, "otel_scope_"), + "Found otel_scope_ prefixed attribute %s in datapoint attributes - these should always be filtered", k) + return true + }) + }) + } +} + func TestMetricGroupData_toNumberDataUnitTest(t *testing.T) { type scrape struct { at int64 diff --git a/receiver/prometheusreceiver/internal/transaction.go b/receiver/prometheusreceiver/internal/transaction.go index 768e448dd17d2..a62beef8267ae 100644 --- a/receiver/prometheusreceiver/internal/transaction.go +++ b/receiver/prometheusreceiver/internal/transaction.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "math" + "sort" + "strings" "github.com/prometheus/common/model" "github.com/prometheus/prometheus/model/exemplar" @@ -37,6 +39,17 @@ var removeStartTimeAdjustment = featuregate.GlobalRegistry().MustRegister( " leave the start time unset. Use the new metricstarttime processor instead."), ) +var RemoveScopeInfoGate = featuregate.GlobalRegistry().MustRegister( + "receiver.prometheusreceiver.RemoveScopeInfo", + featuregate.StageAlpha, + featuregate.WithRegisterDescription("Controls the processing of 'otel_scope_info' metrics. "+ + "The receiver always extracts scope attributes from 'otel_scope_' prefixed labels on all metrics. "+ + "When this feature gate is disabled (legacy mode), 'otel_scope_info' metrics are also processed "+ + "for scope attributes and merged with any 'otel_scope_' labels (with 'otel_scope_' taking precedence). "+ + "When enabled, 'otel_scope_info' metrics are treated as regular metrics and not processed for scope attributes."), + featuregate.WithRegisterReferenceURL("https://github.com/open-telemetry/opentelemetry-specification/pull/4505"), +) + type resourceKey struct { job string instance string @@ -56,13 +69,15 @@ type transaction struct { trimSuffixes bool enableNativeHistograms bool addingNativeHistogram bool // true if the last sample was a native histogram. + removeScopeInfo bool ctx context.Context - families map[resourceKey]map[scopeID]map[metricFamilyKey]*metricFamily + families map[resourceKey]map[string]map[metricFamilyKey]*metricFamily mc scrape.MetricMetadataStore sink consumer.Metrics externalLabels labels.Labels nodeResources map[resourceKey]pcommon.Resource - scopeAttributes map[resourceKey]map[scopeID]pcommon.Map + scopeMap map[resourceKey]map[string]ScopeIdentifier + scopeAttributes map[resourceKey]map[LegacyScopeID]pcommon.Map // Legacy mode only logger *zap.Logger buildInfo component.BuildInfo metricAdjuster MetricsAdjuster @@ -71,14 +86,98 @@ type transaction struct { bufBytes []byte } -var emptyScopeID scopeID +// ScopeIdentifier represents an identifier for a metric scope, which can include +// just the basic scope information or also include scope attributes. +type ScopeIdentifier interface { + Key() string + Name() string + Version() string + SchemaURL() string + Attributes() pcommon.Map + IsEmpty() bool +} -type scopeID struct { +// LegacyScopeID represents a scope identified only by name, version, and schema URL. +// This is used when the removeScopeInfo feature gate is disabled (legacy behavior). +type LegacyScopeID struct { name string version string schemaURL string } +func (l LegacyScopeID) Key() string { + return l.name + "\x0f" + l.version + "\x0f" + l.schemaURL +} + +func (l LegacyScopeID) Name() string { + return l.name +} + +func (l LegacyScopeID) Version() string { + return l.version +} + +func (l LegacyScopeID) SchemaURL() string { + return l.schemaURL +} + +func (LegacyScopeID) Attributes() pcommon.Map { + return pcommon.NewMap() // Return empty but valid map for legacy scopes +} + +func (l LegacyScopeID) IsEmpty() bool { + return l.name == "" && l.version == "" && l.schemaURL == "" +} + +// ScopeID represents a scope identified by name, version, schema URL, and attributes. +// This is used when the removeScopeInfo feature gate is enabled (new behavior). +type ScopeID struct { + name string + version string + schemaURL string + attributes pcommon.Map +} + +func (s ScopeID) Key() string { + // Create a deterministic key that includes attributes + // Using non-printable characters as separators to avoid collisions + key := s.name + "\x0f" + s.version + "\x0f" + s.schemaURL + "\x0f" + if s.attributes.Len() > 0 { + // Sort attribute keys for deterministic ordering + keys := make([]string, 0, s.attributes.Len()) + s.attributes.Range(func(k string, _ pcommon.Value) bool { + keys = append(keys, k) + return true + }) + sort.Strings(keys) + for _, k := range keys { + v, _ := s.attributes.Get(k) + key += k + "\x00" + v.AsString() + "\x01" + } + } + return key +} + +func (s ScopeID) Name() string { + return s.name +} + +func (s ScopeID) Version() string { + return s.version +} + +func (s ScopeID) SchemaURL() string { + return s.schemaURL +} + +func (s ScopeID) Attributes() pcommon.Map { + return s.attributes +} + +func (s ScopeID) IsEmpty() bool { + return s.name == "" && s.version == "" && s.schemaURL == "" && s.attributes.Len() == 0 +} + func newTransaction( ctx context.Context, metricAdjuster MetricsAdjuster, @@ -91,10 +190,11 @@ func newTransaction( ) *transaction { return &transaction{ ctx: ctx, - families: make(map[resourceKey]map[scopeID]map[metricFamilyKey]*metricFamily), + families: make(map[resourceKey]map[string]map[metricFamilyKey]*metricFamily), isNew: true, trimSuffixes: trimSuffixes, enableNativeHistograms: enableNativeHistograms, + removeScopeInfo: RemoveScopeInfoGate.IsEnabled(), sink: sink, metricAdjuster: metricAdjuster, externalLabels: externalLabels, @@ -102,7 +202,8 @@ func newTransaction( buildInfo: settings.BuildInfo, obsrecv: obsrecv, bufBytes: make([]byte, 0, 1024), - scopeAttributes: make(map[resourceKey]map[scopeID]pcommon.Map), + scopeMap: make(map[resourceKey]map[string]ScopeIdentifier), + scopeAttributes: make(map[resourceKey]map[LegacyScopeID]pcommon.Map), nodeResources: map[resourceKey]pcommon.Resource{}, } } @@ -165,13 +266,14 @@ func (t *transaction) Append(_ storage.SeriesRef, ls labels.Labels, atMs int64, return 0, nil } - // For the `otel_scope_info` metric we need to convert it to scope attributes. - if metricName == prometheus.ScopeInfoMetricName { + // For the `otel_scope_info` metric we have different behavior depending on the feature gate. + // It becomes a new scope when the feature gate is disabled, and a regular metric when it is enabled. + if metricName == prometheus.ScopeInfoMetricName && !t.removeScopeInfo { t.addScopeInfo(*rKey, ls) return 0, nil } - scope := getScopeID(ls) + scope := t.getScopeIdentifier(ls) if t.enableNativeHistograms && value.IsStaleNaN(val) { if t.detectAndStoreNativeHistogramStaleness(atMs, rKey, scope, metricName, ls) { @@ -192,7 +294,7 @@ func (t *transaction) Append(_ storage.SeriesRef, ls labels.Labels, atMs int64, // detectAndStoreNativeHistogramStaleness returns true if it detects // and stores a native histogram staleness marker. -func (t *transaction) detectAndStoreNativeHistogramStaleness(atMs int64, key *resourceKey, scope scopeID, metricName string, ls labels.Labels) bool { +func (t *transaction) detectAndStoreNativeHistogramStaleness(atMs int64, key *resourceKey, scope ScopeIdentifier, metricName string, ls labels.Labels) bool { // Detect the special case of stale native histogram series. // Currently Prometheus does not store the histogram type in // its staleness tracker. @@ -223,17 +325,25 @@ func (t *transaction) detectAndStoreNativeHistogramStaleness(atMs int64, key *re // getOrCreateMetricFamily returns the metric family for the given metric name and scope, // and true if an existing family was found. -func (t *transaction) getOrCreateMetricFamily(key resourceKey, scope scopeID, mn string) *metricFamily { +func (t *transaction) getOrCreateMetricFamily(key resourceKey, scope ScopeIdentifier, mn string) *metricFamily { + scopeKey := scope.Key() + if _, ok := t.families[key]; !ok { - t.families[key] = make(map[scopeID]map[metricFamilyKey]*metricFamily) + t.families[key] = make(map[string]map[metricFamilyKey]*metricFamily) } - if _, ok := t.families[key][scope]; !ok { - t.families[key][scope] = make(map[metricFamilyKey]*metricFamily) + if _, ok := t.families[key][scopeKey]; !ok { + t.families[key][scopeKey] = make(map[metricFamilyKey]*metricFamily) } + // Store the scope identifier for later use + if _, ok := t.scopeMap[key]; !ok { + t.scopeMap[key] = make(map[string]ScopeIdentifier) + } + t.scopeMap[key][scopeKey] = scope + mfKey := metricFamilyKey{isExponentialHistogram: t.addingNativeHistogram, name: mn} - curMf, ok := t.families[key][scope][mfKey] + curMf, ok := t.families[key][scopeKey][mfKey] if !ok { fn := mn @@ -241,13 +351,13 @@ func (t *transaction) getOrCreateMetricFamily(key resourceKey, scope scopeID, mn fn = normalizeMetricName(mn) } fnKey := metricFamilyKey{isExponentialHistogram: mfKey.isExponentialHistogram, name: fn} - mf, ok := t.families[key][scope][fnKey] + mf, ok := t.families[key][scopeKey][fnKey] if !ok || !mf.includesMetric(mn) { curMf = newMetricFamily(mn, t.mc, t.logger) if curMf.mtype == pmetric.MetricTypeHistogram && mfKey.isExponentialHistogram { curMf.mtype = pmetric.MetricTypeExponentialHistogram } - t.families[key][scope][metricFamilyKey{isExponentialHistogram: mfKey.isExponentialHistogram, name: curMf.name}] = curMf + t.families[key][scopeKey][metricFamilyKey{isExponentialHistogram: mfKey.isExponentialHistogram, name: curMf.name}] = curMf return curMf } curMf = mf @@ -278,7 +388,7 @@ func (t *transaction) AppendExemplar(_ storage.SeriesRef, l labels.Labels, e exe return 0, errMetricNameNotFound } - mf := t.getOrCreateMetricFamily(*rKey, getScopeID(l), mn) + mf := t.getOrCreateMetricFamily(*rKey, t.getScopeIdentifier(l), mn) mf.addExemplar(t.getSeriesRef(l, mf.mtype), e) return 0, nil @@ -326,7 +436,7 @@ func (t *transaction) AppendHistogram(_ storage.SeriesRef, ls labels.Labels, atM // The `up`, `target_info`, `otel_scope_info` metrics should never generate native histograms, // thus we don't check for them here as opposed to the Append function. - curMF := t.getOrCreateMetricFamily(*rKey, getScopeID(ls), metricName) + curMF := t.getOrCreateMetricFamily(*rKey, t.getScopeIdentifier(ls), metricName) if h != nil && h.CounterResetHint == histogram.GaugeType || fh != nil && fh.CounterResetHint == histogram.GaugeType { t.logger.Warn("dropping unsupported gauge histogram datapoint", zap.String("metric_name", metricName), zap.Any("labels", ls)) @@ -383,7 +493,7 @@ func (t *transaction) setCreationTimestamp(ls labels.Labels, atMs, ctMs int64) ( return 0, errMetricNameNotFound } - curMF := t.getOrCreateMetricFamily(*rKey, getScopeID(ls), metricName) + curMF := t.getOrCreateMetricFamily(*rKey, t.getScopeIdentifier(ls), metricName) seriesRef := t.getSeriesRef(ls, curMF.mtype) curMF.addCreationTimestamp(seriesRef, ls, atMs, ctMs) @@ -421,26 +531,28 @@ func (t *transaction) getMetrics() (pmetric.Metrics, error) { rms := md.ResourceMetrics().AppendEmpty() resource.CopyTo(rms.Resource()) - for scope, mfs := range families { + for scopeKey, mfs := range families { + scope, ok := t.scopeMap[rKey][scopeKey] + if !ok { + continue // Should not happen, but skip if scope not found + } + ils := rms.ScopeMetrics().AppendEmpty() // If metrics don't include otel_scope_name or otel_scope_version // labels, use the receiver name and version. - if scope == emptyScopeID { + if scope.IsEmpty() { ils.Scope().SetName(mdata.ScopeName) ils.Scope().SetVersion(t.buildInfo.Version) } else { // Otherwise, use the scope that was provided with the metrics. - ils.Scope().SetName(scope.name) - ils.Scope().SetVersion(scope.version) - if scope.schemaURL != "" { - ils.SetSchemaUrl(scope.schemaURL) + ils.Scope().SetName(scope.Name()) + ils.Scope().SetVersion(scope.Version()) + if scope.SchemaURL() != "" { + ils.SetSchemaUrl(scope.SchemaURL()) } - // If we got an otel_scope_info metric for that scope, get scope - // attributes from it. - if scopeAttributes, ok := t.scopeAttributes[rKey]; ok { - if attributes, ok := scopeAttributes[scope]; ok { - attributes.CopyTo(ils.Scope().Attributes()) - } + // Copy scope attributes if they exist + if scope.Attributes().Len() > 0 { + scope.Attributes().CopyTo(ils.Scope().Attributes()) } } metrics := ils.Metrics() @@ -467,17 +579,54 @@ func (t *transaction) getMetrics() (pmetric.Metrics, error) { return md, nil } -func getScopeID(ls labels.Labels) scopeID { - var scope scopeID +func (t *transaction) getScopeIdentifier(ls labels.Labels) ScopeIdentifier { + // Check if this is an otel_scope_info metric when feature gate is enabled + metricName := ls.Get("__name__") + if t.removeScopeInfo && metricName == prometheus.ScopeInfoMetricName { + // When feature gate is enabled, otel_scope_info should extract only basic scope info + // (name, version, schema_url) but NOT other otel_scope_ attributes + scope := ScopeID{ + attributes: pcommon.NewMap(), + } + ls.Range(func(lbl labels.Label) { + switch lbl.Name { + case prometheus.ScopeNameLabelKey: + scope.name = lbl.Value + case prometheus.ScopeVersionLabelKey: + scope.version = lbl.Value + case prometheus.ScopeSchemaURLLabelKey: + scope.schemaURL = lbl.Value + // Don't extract other otel_scope_ labels as scope attributes for otel_scope_info when gate is enabled + } + }) + return scope + } + + // Always extract otel_scope_ labels as scope attributes for all other cases + return t.getScopeWithAttributes(ls) +} + +func (*transaction) getScopeWithAttributes(ls labels.Labels) ScopeID { + scope := ScopeID{ + attributes: pcommon.NewMap(), + } + ls.Range(func(lbl labels.Label) { - if lbl.Name == prometheus.ScopeNameLabelKey { + switch { + case lbl.Name == prometheus.ScopeNameLabelKey: scope.name = lbl.Value - } - if lbl.Name == prometheus.ScopeVersionLabelKey { + return + case lbl.Name == prometheus.ScopeVersionLabelKey: scope.version = lbl.Value - } - if lbl.Name == prometheus.ScopeSchemaURLLabelKey { + return + case lbl.Name == prometheus.ScopeSchemaURLLabelKey: scope.schemaURL = lbl.Value + return + case strings.HasPrefix(lbl.Name, "otel_scope_"): + // Extract scope attributes from otel_scope_ prefixed labels + attrName := strings.TrimPrefix(lbl.Name, "otel_scope_") + scope.attributes.PutStr(attrName, lbl.Value) + return } }) return scope @@ -590,32 +739,102 @@ func (t *transaction) AddTargetInfo(key resourceKey, ls labels.Labels) { func (t *transaction) addScopeInfo(key resourceKey, ls labels.Labels) { t.addingNativeHistogram = false - attrs := pcommon.NewMap() - scope := scopeID{} + + // Extract scope information from otel_scope_info metric + scopeInfoAttrs := pcommon.NewMap() + scopeFromInfo := ScopeID{ + attributes: pcommon.NewMap(), + } + ls.Range(func(lbl labels.Label) { if lbl.Name == model.JobLabel || lbl.Name == model.InstanceLabel || lbl.Name == model.MetricNameLabel { return } - if lbl.Name == prometheus.ScopeNameLabelKey { - scope.name = lbl.Value - return + switch lbl.Name { + case prometheus.ScopeNameLabelKey: + scopeFromInfo.name = lbl.Value + case prometheus.ScopeVersionLabelKey: + scopeFromInfo.version = lbl.Value + case prometheus.ScopeSchemaURLLabelKey: + scopeFromInfo.schemaURL = lbl.Value + default: + // All other labels from otel_scope_info become scope attributes + scopeInfoAttrs.PutStr(lbl.Name, lbl.Value) } - if lbl.Name == prometheus.ScopeVersionLabelKey { - scope.version = lbl.Value - return + }) + + // Initialize scope map if needed + if _, ok := t.scopeMap[key]; !ok { + t.scopeMap[key] = make(map[string]ScopeIdentifier) + } + + // Look for existing scopes with the same name/version/schema (ignoring attributes for now) + var existingScopeKey string + var existingScope ScopeID + var found bool + + for scopeKey, scope := range t.scopeMap[key] { + if scopeID, ok := scope.(ScopeID); ok { + if scopeID.name == scopeFromInfo.name && + scopeID.version == scopeFromInfo.version && + scopeID.schemaURL == scopeFromInfo.schemaURL { + existingScopeKey = scopeKey + existingScope = scopeID + found = true + break + } } - if lbl.Name == prometheus.ScopeSchemaURLLabelKey { - scope.schemaURL = lbl.Value - return + } + + if found { + // Remove the old scope entry + delete(t.scopeMap[key], existingScopeKey) + + // Merge attributes: otel_scope_ labels take precedence over otel_scope_info. + mergedAttrs := pcommon.NewMap() + scopeInfoAttrs.CopyTo(mergedAttrs) + existingScope.attributes.Range(func(k string, v pcommon.Value) bool { + mergedAttrs.PutStr(k, v.AsString()) + return true + }) + + // Create merged scope with new key + mergedScope := ScopeID{ + name: scopeFromInfo.name, + version: scopeFromInfo.version, + schemaURL: scopeFromInfo.schemaURL, + attributes: mergedAttrs, } - attrs.PutStr(lbl.Name, lbl.Value) - }) - if _, ok := t.scopeAttributes[key]; !ok { - t.scopeAttributes[key] = make(map[scopeID]pcommon.Map) + t.scopeMap[key][mergedScope.Key()] = mergedScope + + // Update any existing metric families that were using the old scope key + if t.families[key] != nil { + if familiesForOldScope, exists := t.families[key][existingScopeKey]; exists { + t.families[key][mergedScope.Key()] = familiesForOldScope + delete(t.families[key], existingScopeKey) + } + } + } else { + // No existing scope, just store the otel_scope_info attributes + scopeFromInfo.attributes = scopeInfoAttrs + t.scopeMap[key][scopeFromInfo.Key()] = scopeFromInfo } - t.scopeAttributes[key][scope] = attrs } func getSeriesRef(bytes []byte, ls labels.Labels, mtype pmetric.MetricType) (uint64, []byte) { - return ls.HashWithoutLabels(bytes, getSortedNotUsefulLabels(mtype)...) + excludeLabels := getSortedNotUsefulLabels(mtype) + + // Always exclude otel_scope_ prefixed labels from series reference hash generation + // as they are extracted as scope attributes instead of datapoint attributes + var scopeLabels []string + ls.Range(func(l labels.Label) { + if strings.HasPrefix(l.Name, "otel_scope_") { + scopeLabels = append(scopeLabels, l.Name) + } + }) + if len(scopeLabels) > 0 { + excludeLabels = append(excludeLabels, scopeLabels...) + } + + return ls.HashWithoutLabels(bytes, excludeLabels...) } diff --git a/receiver/prometheusreceiver/internal/transaction_bench_test.go b/receiver/prometheusreceiver/internal/transaction_bench_test.go new file mode 100644 index 0000000000000..12f74e06fe900 --- /dev/null +++ b/receiver/prometheusreceiver/internal/transaction_bench_test.go @@ -0,0 +1,174 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package internal + +import ( + "testing" + "time" + + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/config" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/scrape" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/consumer/consumertest" + "go.opentelemetry.io/collector/receiver/receivertest" +) + +// createBenchmarkTransaction creates a transaction for benchmark testing +func createBenchmarkTransaction(tb testing.TB) *transaction { + target := scrape.NewTarget( + labels.FromMap(map[string]string{ + model.InstanceLabel: "localhost:8080", + }), + &config.ScrapeConfig{}, + map[model.LabelName]model.LabelValue{ + model.AddressLabel: "address:8080", + model.SchemeLabel: "http", + }, + nil, + ) + + scrapeCtx := scrape.ContextWithMetricMetadataStore( + scrape.ContextWithTarget(tb.Context(), target), + testMetadataStore(testMetadata)) + + return newTransaction( + scrapeCtx, + &startTimeAdjuster{startTime: startTimestamp}, + new(consumertest.MetricsSink), + labels.EmptyLabels(), + receivertest.NewNopSettings(receivertest.NopType), + nopObsRecv(tb), + false, // trimSuffixes + false, // enableNativeHistograms + ) +} + +// createTestLabels creates various types of prometheus labels for benchmarking +func createTestLabels(metricName string, additionalLabels ...string) labels.Labels { + lbls := []string{ + model.MetricNameLabel, metricName, + model.JobLabel, "benchmark-job", + model.InstanceLabel, "localhost:8080", + } + lbls = append(lbls, additionalLabels...) + return labels.FromStrings(lbls...) +} + +// BenchmarkTransactionAppendAndCommit benchmarks the core processing pipeline (Append + Commit) +func BenchmarkTransactionAppendAndCommit(b *testing.B) { + b.Run("Floats", func(b *testing.B) { + benchmarkFloat(b) + }) + b.Run("Histogram", func(b *testing.B) { + benchmarkHistogram(b) + }) +} + +func benchmarkFloat(b *testing.B) { + tr := createBenchmarkTransaction(b) + metricName := "metric" + labels := []string{"method", "GET", "status", "200"} + lbls := createTestLabels(metricName, labels...) + timestamp := time.Now().Unix() * 1000 + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, err := tr.Append(0, lbls, timestamp, float64(i)) + require.NoError(b, err) + + targetInfoLabels := createTestLabels("target_info", + "version", "1.2.3", + "service_name", "benchmark-service", + "environment", "test") + _, err = tr.Append(0, targetInfoLabels, timestamp, 1.0) + require.NoError(b, err) + + scopeInfoLabels := createTestLabels("otel_scope_info", + "otel_scope_name", "benchmark-scope", + "otel_scope_version", "1.0.0", + "otel_scope_schema_url", "https://example.com/schema", + "custom_attr", "benchmark_value") + _, err = tr.Append(0, scopeInfoLabels, timestamp, 1.0) + require.NoError(b, err) + + newScopeLabels := createTestLabels(metricName+"_with_scope_labels", + append(labels, + "otel_scope_name", "benchmark-scope", + "otel_scope_version", "1.0.0", + "otel_scope_schema_url", "https://example.com/schema", + "otel_scope_custom_attr", "benchmark_value")...) + _, err = tr.Append(0, newScopeLabels, timestamp, float64(i)) + require.NoError(b, err) + } + + err := tr.Commit() + require.NoError(b, err) +} + +func benchmarkHistogram(b *testing.B) { + tr := createBenchmarkTransaction(b) + + // Create histogram buckets + buckets := []string{ + "le", "0.1", + "le", "0.5", + "le", "1.0", + "le", "5.0", + "le", "+Inf", + } + + timestamp := time.Now().Unix() * 1000 + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + targetInfoLabels := createTestLabels("target_info", + "version", "2.1.0", + "service_name", "web-api", + "environment", "production", + "region", "us-west-2") + _, err := tr.Append(0, targetInfoLabels, timestamp, 1.0) + require.NoError(b, err) + + scopeInfoLabels := createTestLabels("otel_scope_info", + "otel_scope_name", "http-server", + "otel_scope_version", "2.1.0", + "otel_scope_schema_url", "https://opentelemetry.io/schemas/1.17.0", + "service_name", "web-api") + _, err = tr.Append(0, scopeInfoLabels, timestamp, 1.0) + require.NoError(b, err) + + for j := 0; j < len(buckets); j += 2 { + lbls := createTestLabels("http_duration_bucket", "method", "GET", buckets[j], buckets[j+1]) + _, err = tr.Append(0, lbls, timestamp, float64(i*10+j/2)) + require.NoError(b, err) + } + + sumLabels := createTestLabels("http_duration_sum", "method", "GET") + _, err = tr.Append(0, sumLabels, timestamp, float64(i*100)) + require.NoError(b, err) + + countLabels := createTestLabels("http_duration_count", "method", "GET") + _, err = tr.Append(0, countLabels, timestamp, float64(i*20)) + require.NoError(b, err) + + newScopeBucketLabels := createTestLabels("request_duration_bucket_with_scope", + "method", "POST", + "le", "1.0", + "otel_scope_name", "http-server", + "otel_scope_version", "2.1.0", + "otel_scope_schema_url", "https://opentelemetry.io/schemas/1.17.0", + "otel_scope_service_name", "web-api") + _, err = tr.Append(0, newScopeBucketLabels, timestamp, float64(i*5)) + require.NoError(b, err) + } + + err := tr.Commit() + require.NoError(b, err) +} diff --git a/receiver/prometheusreceiver/internal/transaction_test.go b/receiver/prometheusreceiver/internal/transaction_test.go index 49e42ef48660a..c9e77bafa5816 100644 --- a/receiver/prometheusreceiver/internal/transaction_test.go +++ b/receiver/prometheusreceiver/internal/transaction_test.go @@ -735,13 +735,13 @@ func TestAppendHistogramCTZeroSample(t *testing.T) { ) } -func nopObsRecv(t *testing.T) *receiverhelper.ObsReport { +func nopObsRecv(tb testing.TB) *receiverhelper.ObsReport { obsrecv, err := receiverhelper.NewObsReport(receiverhelper.ObsReportSettings{ ReceiverID: component.MustNewID("prometheus"), Transport: transport, ReceiverCreateSettings: receivertest.NewNopSettings(receivertest.NopType), }) - require.NoError(t, err) + require.NoError(tb, err) return obsrecv } diff --git a/receiver/prometheusreceiver/metrics_receiver_labels_test.go b/receiver/prometheusreceiver/metrics_receiver_labels_test.go index a04b9b8e93e90..7f2addfa5234c 100644 --- a/receiver/prometheusreceiver/metrics_receiver_labels_test.go +++ b/receiver/prometheusreceiver/metrics_receiver_labels_test.go @@ -4,6 +4,7 @@ package prometheusreceiver import ( + "strings" "testing" "github.com/prometheus/prometheus/model/labels" @@ -12,6 +13,9 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/pmetric" semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + + "github.com/open-telemetry/opentelemetry-collector-contrib/internal/common/testutil" + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver/internal" ) const targetExternalLabels = ` @@ -822,7 +826,7 @@ func verifyTargetInfoResourceAttributes(t *testing.T, td *testData, rms []pmetri }) } -const targetInstrumentationScopes = ` +const targetInstrumentationScopeInfoMetric = ` # HELP jvm_memory_bytes_used Used bytes of a given JVM memory area. # TYPE jvm_memory_bytes_used gauge jvm_memory_bytes_used{area="heap", otel_scope_name="fake.scope.name", otel_scope_version="v0.1.0", otel_scope_schema_url="https://opentelemetry.io/schemas/1.21.0"} 100 @@ -832,21 +836,141 @@ jvm_memory_bytes_used{area="heap"} 100 otel_scope_info{animal="bear", otel_scope_name="scope.with.attributes", otel_scope_version="v1.5.0"} 1 ` -func TestScopeInfoScopeAttributes(t *testing.T) { - targets := []*testData{ - { - name: "target1", - pages: []mockPrometheusResponse{ - {code: 200, data: targetInstrumentationScopes}, +const targetInstrumentationScopeLabels = ` +# HELP jvm_memory_bytes_used Used bytes of a given JVM memory area. +# TYPE jvm_memory_bytes_used gauge +jvm_memory_bytes_used{area="heap", otel_scope_name="fake.scope.name", otel_scope_version="v0.1.0", otel_scope_schema_url="https://opentelemetry.io/schemas/1.21.0"} 100 +jvm_memory_bytes_used{area="heap", otel_scope_name="scope.with.attributes", otel_scope_version="v1.5.0", otel_scope_animal="bear", otel_scope_color="blue"} 200 +jvm_memory_bytes_used{area="heap"} 100 +` + +const targetMixedScopeInfoAndLabels = ` +# HELP jvm_memory_bytes_used Used bytes of a given JVM memory area. +# TYPE jvm_memory_bytes_used gauge +jvm_memory_bytes_used{area="heap", otel_scope_name="scope.from.labels", otel_scope_version="v1.0.0", otel_scope_animal="bear", otel_scope_environment="staging"} 100 +jvm_memory_bytes_used{area="heap", otel_scope_name="scope.from.info", otel_scope_version="v2.0.0", otel_scope_animal="cat"} 200 +jvm_memory_bytes_used{area="heap"} 300 +# TYPE otel_scope_info gauge +otel_scope_info{otel_scope_name="scope.from.info", otel_scope_version="v2.0.0", team="backend", environment="production"} 1 +` + +const targetMixedWithConflicts = ` +# HELP jvm_memory_bytes_used Used bytes of a given JVM memory area. +# TYPE jvm_memory_bytes_used gauge +jvm_memory_bytes_used{area="heap", otel_scope_name="conflicted.scope", otel_scope_version="v1.0.0", otel_scope_animal="bear", otel_scope_team="frontend"} 100 +jvm_memory_bytes_used{area="heap"} 200 +# TYPE otel_scope_info gauge +otel_scope_info{otel_scope_name="conflicted.scope", otel_scope_version="v1.0.0", animal="cat", team="backend", environment="production"} 1 +` + +// TestScopeAttributes tests scope attribute extraction behavior. +// Note: Some tests may fail until the implementation is updated to match the target behavior +// defined in the implementation plan (always extract otel_scope_ labels as scope attributes). +func TestScopeAttributes(t *testing.T) { + t.Run("ScopeInfoMetric", func(t *testing.T) { + // Test parsing otel_scope_info metric (feature gate disabled) + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, false)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetInstrumentationScopeInfoMetric}, + }, + validateFunc: verifyTargetWithScopeInfoMetric, }, - validateFunc: verifyMultipleScopes, - }, - } + } - testComponent(t, targets, nil) + testComponent(t, targets, nil) + }) + + t.Run("ScopeLabels/feature_enabled", func(t *testing.T) { + // Test parsing otel_scope_ labels (feature gate enabled) + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, true)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetInstrumentationScopeLabels}, + }, + validateFunc: verifyTargetWithScopeLabels, + }, + } + + testComponent(t, targets, nil) + }) + + t.Run("ScopeLabels/feature_disabled", func(t *testing.T) { + // Test parsing otel_scope_ labels (feature gate disabled) + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, false)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetInstrumentationScopeLabels}, + }, + validateFunc: verifyTargetWithScopeLabels, + }, + } + + testComponent(t, targets, nil) + }) + + t.Run("Mixed/ScopeInfoAndLabels/feature_disabled", func(t *testing.T) { + // Test mixed scenario: both otel_scope_info and otel_scope_ labels (feature gate disabled) + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, false)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetMixedScopeInfoAndLabels}, + }, + validateFunc: verifyMixedScopeInfoAndLabels, + }, + } + + testComponent(t, targets, nil) + }) + + t.Run("Mixed/ScopeInfoAndLabels/feature_enabled", func(t *testing.T) { + // Test mixed scenario: both otel_scope_info and otel_scope_ labels (feature gate enabled) + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, true)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetMixedScopeInfoAndLabels}, + }, + validateFunc: verifyMixedScopeInfoAndLabelsFeatureEnabled, + }, + } + + testComponent(t, targets, nil) + }) + + t.Run("Mixed/ConflictResolution/feature_disabled", func(t *testing.T) { + // Test conflict resolution: otel_scope_ labels should win over otel_scope_info + defer testutil.SetFeatureGateForTest(t, internal.RemoveScopeInfoGate, false)() + + targets := []*testData{ + { + name: "target1", + pages: []mockPrometheusResponse{ + {code: 200, data: targetMixedWithConflicts}, + }, + validateFunc: verifyMixedConflictResolution, + }, + } + + testComponent(t, targets, nil) + }) } -func verifyMultipleScopes(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { +func verifyTargetWithScopeInfoMetric(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { verifyNumValidScrapeResults(t, td, rms) require.NotEmpty(t, rms, "At least one resource metric should be present") @@ -879,3 +1003,252 @@ func verifyMultipleScopes(t *testing.T, td *testData, rms []pmetric.ResourceMetr _, found = dp.Attributes().Get("area") require.True(t, found, "Should only have 'area' attribute") } + +func verifyTargetWithScopeLabels(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { + verifyNumValidScrapeResults(t, td, rms) + require.NotEmpty(t, rms, "At least one resource metric should be present") + + sms := rms[0].ScopeMetrics() + require.Equal(t, 3, sms.Len(), "Three scope metrics should be present in new mode") + sms.Sort(func(a, b pmetric.ScopeMetrics) bool { + return a.Scope().Name() < b.Scope().Name() + }) + + // Verify first scope: fake.scope.name (no attributes) + require.Equal(t, "fake.scope.name", sms.At(0).Scope().Name()) + require.Equal(t, "v0.1.0", sms.At(0).Scope().Version()) + require.Equal(t, "https://opentelemetry.io/schemas/1.21.0", sms.At(0).SchemaUrl()) + require.Equal(t, 0, sms.At(0).Scope().Attributes().Len()) + + // Verify second scope: default receiver scope (no attributes) + require.Equal(t, "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver", sms.At(1).Scope().Name()) + require.Empty(t, sms.At(1).SchemaUrl()) + require.Equal(t, 0, sms.At(1).Scope().Attributes().Len()) + + // Verify third scope: scope.with.attributes with attributes from otel_scope_ labels + require.Equal(t, "scope.with.attributes", sms.At(2).Scope().Name()) + require.Equal(t, "v1.5.0", sms.At(2).Scope().Version()) + require.Empty(t, sms.At(2).SchemaUrl()) + require.Equal(t, 2, sms.At(2).Scope().Attributes().Len()) + animalVal, found := sms.At(2).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "bear", animalVal.Str()) + colorVal, found := sms.At(2).Scope().Attributes().Get("color") + require.True(t, found) + require.Equal(t, "blue", colorVal.Str()) + + // Check that otel_scope_ labels are filtered from metric data point attributes + require.Equal(t, 1, sms.At(2).Metrics().Len()) + metric := sms.At(2).Metrics().At(0) + dp := metric.Gauge().DataPoints().At(0) + require.Equal(t, 1, dp.Attributes().Len(), "Should only have 'area' attribute, otel_scope_ labels should be filtered") + _, found = dp.Attributes().Get("area") + require.True(t, found, "Should have 'area' attribute") + _, found = dp.Attributes().Get("otel_scope_animal") + require.False(t, found, "Should not have otel_scope_animal attribute in datapoint") + _, found = dp.Attributes().Get("otel_scope_color") + require.False(t, found, "Should not have otel_scope_color attribute in datapoint") + + // Verify scope grouping: Test that scope attributes are correctly extracted and grouped + // scope.with.attributes should have 1 metric with 2 attributes (animal=bear, color=blue) + scopeWithAttrs := sms.At(2).Scope() + require.Equal(t, "scope.with.attributes", scopeWithAttrs.Name()) + require.Equal(t, 1, sms.At(2).Metrics().Len(), "scope.with.attributes should have 1 metric") + require.Equal(t, 2, scopeWithAttrs.Attributes().Len(), "scope.with.attributes should have 2 attributes") + + // Verify that the default receiver scope has multiple metrics (including the one without otel_scope_ labels) + defaultScope := sms.At(1) + require.GreaterOrEqual(t, defaultScope.Metrics().Len(), 1, "default scope should have at least 1 metric") + require.Equal(t, 0, defaultScope.Scope().Attributes().Len(), "default scope should have no attributes") +} + +// verifyMixedScopeInfoAndLabels tests the target behavior where both otel_scope_info metrics +// and otel_scope_ labels are present, with feature gate that removes scope info parsing disabled. +// attributes from otel_scope_info should be merged with attributes from otel_scope_ labels when scope is the same. +func verifyMixedScopeInfoAndLabels(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { + verifyNumValidScrapeResults(t, td, rms) + require.NotEmpty(t, rms, "At least one resource metric should be present") + + sms := rms[0].ScopeMetrics() + require.Equal(t, 3, sms.Len(), "Three scope metrics should be present") + sms.Sort(func(a, b pmetric.ScopeMetrics) bool { + return a.Scope().Name() < b.Scope().Name() + }) + + // Verify default receiver scope (no attributes) + // This one is used for the metric without any otel_scope_ labels + require.Equal(t, "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver", sms.At(0).Scope().Name()) + require.Equal(t, 0, sms.At(0).Scope().Attributes().Len()) + + // Verify scope.from.info: should have attributes from otel_scope_info, plus the one from otel_scope_ labels + require.Equal(t, "scope.from.info", sms.At(1).Scope().Name()) + require.Equal(t, "v2.0.0", sms.At(1).Scope().Version()) + require.Equal(t, 3, sms.At(1).Scope().Attributes().Len()) + teamVal, found := sms.At(1).Scope().Attributes().Get("team") + require.True(t, found) + require.Equal(t, "backend", teamVal.Str()) + envVal, found := sms.At(1).Scope().Attributes().Get("environment") + require.True(t, found) + require.Equal(t, "production", envVal.Str()) + animalVal, found := sms.At(1).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "cat", animalVal.Str()) + + // Verify scope.from.labels: should have attributes from otel_scope_ labels + require.Equal(t, "scope.from.labels", sms.At(2).Scope().Name()) + require.Equal(t, "v1.0.0", sms.At(2).Scope().Version()) + require.Equal(t, 2, sms.At(2).Scope().Attributes().Len()) + animalVal, found = sms.At(2).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "bear", animalVal.Str()) + envVal, found = sms.At(2).Scope().Attributes().Get("environment") + require.True(t, found) + require.Equal(t, "staging", envVal.Str()) + + // Verify that otel_scope_ labels are filtered from datapoint attributes + for i := 0; i < sms.Len(); i++ { + scope := sms.At(i) + for j := 0; j < scope.Metrics().Len(); j++ { + metric := scope.Metrics().At(j) + if metric.Type() == pmetric.MetricTypeGauge { + for k := 0; k < metric.Gauge().DataPoints().Len(); k++ { + dp := metric.Gauge().DataPoints().At(k) + dp.Attributes().Range(func(key string, _ pcommon.Value) bool { + require.False(t, strings.HasPrefix(key, "otel_scope_"), + "Found otel_scope_ prefixed attribute %s in datapoint - should be filtered", key) + return true + }) + } + } + } + } +} + +// verifyMixedScopeInfoAndLabelsFeatureEnabled tests the behavior when feature gate is enabled: +// otel_scope_ labels should be extracted as scope attributes, otel_scope_info should be treated as regular metric. +func verifyMixedScopeInfoAndLabelsFeatureEnabled(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { + verifyNumValidScrapeResults(t, td, rms) + require.NotEmpty(t, rms, "At least one resource metric should be present") + + sms := rms[0].ScopeMetrics() + require.Equal(t, 4, sms.Len(), "Four scope metrics should be present") + sms.Sort(func(a, b pmetric.ScopeMetrics) bool { + return a.Scope().Name() < b.Scope().Name() + }) + + // Verify default receiver scope (no attributes) + // This one is used for the metric without any otel_scope_ labels + require.Equal(t, "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver", sms.At(0).Scope().Name()) + require.Equal(t, 0, sms.At(0).Scope().Attributes().Len()) + + // Verify scope.from.info(jvm_memory_bytes_used): should have only attributes from otel_scope_ labels (otel_scope_info is not processed) + require.Equal(t, "scope.from.info", sms.At(1).Scope().Name()) + require.Equal(t, "v2.0.0", sms.At(1).Scope().Version()) + require.Equal(t, 1, sms.At(1).Scope().Attributes().Len(), "Should have only attributes from otel_scope_ labels when feature gate is enabled") + animalVal, found := sms.At(1).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "cat", animalVal.Str()) + _, found = sms.At(1).Scope().Attributes().Get("team") + require.False(t, found) + _, found = sms.At(1).Scope().Attributes().Get("environment") + require.False(t, found) + + // Verify scope.from.info(otel_scope_info): It should become a new scope, since it has different attributes from jvm_memory_bytes_used + require.Equal(t, "scope.from.info", sms.At(2).Scope().Name()) + require.Equal(t, "v2.0.0", sms.At(2).Scope().Version()) + require.Equal(t, 0, sms.At(2).Scope().Attributes().Len()) + // Verify otel_scope_info has become a datapoint, and team and environment are now datapoint attributes + require.Equal(t, 1, sms.At(2).Metrics().Len()) + metric := sms.At(2).Metrics().At(0) + require.Equal(t, "otel_scope_info", metric.Name()) + require.Equal(t, pmetric.MetricTypeGauge, metric.Type()) + require.Equal(t, 1, metric.Gauge().DataPoints().Len()) + dp := metric.Gauge().DataPoints().At(0) + require.Equal(t, 2, dp.Attributes().Len()) + teamVal, found := dp.Attributes().Get("team") + require.True(t, found) + require.Equal(t, "backend", teamVal.Str()) + envVal, found := dp.Attributes().Get("environment") + require.True(t, found) + require.Equal(t, "production", envVal.Str()) + + // Verify scope.from.labels: should have attributes from otel_scope_ labels + require.Equal(t, "scope.from.labels", sms.At(3).Scope().Name()) + require.Equal(t, "v1.0.0", sms.At(3).Scope().Version()) + require.Equal(t, 2, sms.At(3).Scope().Attributes().Len()) + animalVal, found = sms.At(3).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "bear", animalVal.Str()) + envVal, found = sms.At(3).Scope().Attributes().Get("environment") + require.True(t, found) + require.Equal(t, "staging", envVal.Str()) + + // Verify that otel_scope_ labels are filtered from datapoint attributes + for i := 0; i < sms.Len(); i++ { + scope := sms.At(i) + for j := 0; j < scope.Metrics().Len(); j++ { + metric := scope.Metrics().At(j) + if metric.Type() == pmetric.MetricTypeGauge && metric.Name() != "otel_scope_info" { + for k := 0; k < metric.Gauge().DataPoints().Len(); k++ { + dp := metric.Gauge().DataPoints().At(k) + dp.Attributes().Range(func(key string, _ pcommon.Value) bool { + require.False(t, strings.HasPrefix(key, "otel_scope_"), + "Found otel_scope_ prefixed attribute %s in datapoint - should be filtered", key) + return true + }) + } + } + } + } +} + +// verifyMixedConflictResolution tests that otel_scope_ labels take precedence over otel_scope_info +// attributes when there are conflicts. +func verifyMixedConflictResolution(t *testing.T, td *testData, rms []pmetric.ResourceMetrics) { + verifyNumValidScrapeResults(t, td, rms) + require.NotEmpty(t, rms, "At least one resource metric should be present") + + sms := rms[0].ScopeMetrics() + require.Equal(t, 2, sms.Len(), "Two scope metrics should be present") + sms.Sort(func(a, b pmetric.ScopeMetrics) bool { + return a.Scope().Name() < b.Scope().Name() + }) + + // Verify conflicted.scope: otel_scope_ labels should win over otel_scope_info + require.Equal(t, "conflicted.scope", sms.At(0).Scope().Name()) + require.Equal(t, "v1.0.0", sms.At(0).Scope().Version()) + require.Equal(t, 3, sms.At(0).Scope().Attributes().Len()) + + // Verify otel_scope_ labels won (animal="bear", team="frontend") + animalVal, found := sms.At(0).Scope().Attributes().Get("animal") + require.True(t, found) + require.Equal(t, "bear", animalVal.Str(), "otel_scope_ label should win over otel_scope_info") + teamVal, found := sms.At(0).Scope().Attributes().Get("team") + require.True(t, found) + require.Equal(t, "frontend", teamVal.Str(), "otel_scope_ label should win over otel_scope_info") + + // Verify otel_scope_info attribute without conflict is preserved + envVal, found := sms.At(0).Scope().Attributes().Get("environment") + require.True(t, found) + require.Equal(t, "production", envVal.Str(), "otel_scope_info attribute should be preserved when no conflict") + + // Verify default receiver scope + require.Equal(t, "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver", sms.At(1).Scope().Name()) + require.Equal(t, 0, sms.At(1).Scope().Attributes().Len()) + + // Verify that otel_scope_ labels are filtered from datapoint attributes + conflictedScope := sms.At(0) + for j := 0; j < conflictedScope.Metrics().Len(); j++ { + metric := conflictedScope.Metrics().At(j) + if metric.Type() == pmetric.MetricTypeGauge { + for k := 0; k < metric.Gauge().DataPoints().Len(); k++ { + dp := metric.Gauge().DataPoints().At(k) + dp.Attributes().Range(func(key string, _ pcommon.Value) bool { + require.False(t, strings.HasPrefix(key, "otel_scope_"), + "Found otel_scope_ prefixed attribute %s in datapoint - should be filtered", key) + return true + }) + } + } + } +}