Files
72cfe1b4c3 feat: allow span filtering in service graph processor (#6453)
* added spanfilter to filter out spans

* return the error if the filters are not correctly formatted

* add filters to the userconfigurable overrides

* add core logic for drop items cache

* wired up everything

* some tweaks

* refactor

* fmt and manifest

* tighten names

* rename metric

* drop already buffered counterparts when a span is filter out

* add a new test and documentation

* Update docs/sources/tempo/operations/manage-advanced-systems/user-configurable-overrides.md

Co-authored-by: Suraj Nath <[email protected]>

* Update docs/sources/tempo/configuration/_index.md

Co-authored-by: Suraj Nath <[email protected]>

* Update docs/sources/tempo/configuration/_index.md

Co-authored-by: Suraj Nath <[email protected]>

* bail early when the span kind is not client or server

* Update docs/sources/tempo/operations/manage-advanced-systems/user-configurable-overrides.md

Co-authored-by: Suraj Nath <[email protected]>

---------

Co-authored-by: Suraj Nath <[email protected]>
2026-02-22 00:55:24 -08:00

529 lines
18 KiB
Go

package overrides
import (
"bytes"
"encoding/json"
"flag"
"reflect"
"slices"
"testing"
"time"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v2"
"github.com/grafana/tempo/modules/overrides/histograms"
"github.com/grafana/tempo/modules/overrides/userconfigurable/client"
"github.com/grafana/tempo/pkg/sharedconfig"
filterconfig "github.com/grafana/tempo/pkg/spanfilter/config"
"github.com/grafana/tempo/pkg/util/listtomap"
"github.com/grafana/tempo/tempodb/backend"
)
// Copied from Cortex
func TestConfigTagsYamlMatchJson(t *testing.T) {
overrides := reflect.TypeOf(LegacyOverrides{})
n := overrides.NumField()
var mismatch []string
for i := 0; i < n; i++ {
field := overrides.Field(i)
// Note that we aren't requiring YAML and JSON tags to match, just that
// they either both exist or both don't exist.
hasYAMLTag := field.Tag.Get("yaml") != ""
hasJSONTag := field.Tag.Get("json") != ""
if hasYAMLTag != hasJSONTag {
mismatch = append(mismatch, field.Name)
}
}
assert.Empty(t, mismatch, "expected no mismatched JSON and YAML tags")
}
// Copied from Cortex and modified
func TestConfigYamlMatchJson(t *testing.T) {
inputYAML := `
ingestion_rate_strategy: global
ingestion_rate_limit_bytes: 100_000
ingestion_burst_size_bytes: 100_000
ingestion_tenant_shard_size: 3
ingestion_max_attribute_bytes: 1_000
max_traces_per_user: 1000
max_global_traces_per_user: 1000
max_bytes_per_trace: 100_000
block_retention: 24h
compaction_window: 4h
per_tenant_override_config: /etc/Overrides.yaml
per_tenant_override_period: 1m
metrics_generator_send_queue_size: 10
metrics_generator_send_workers: 1
max_search_duration: 5m
`
inputJSON := `
{
"ingestion_rate_strategy": "global",
"ingestion_rate_limit_bytes": 100000,
"ingestion_burst_size_bytes": 100000,
"ingestion_tenant_shard_size": 3,
"ingestion_max_attribute_bytes": 1000,
"max_traces_per_user": 1000,
"max_global_traces_per_user": 1000,
"max_bytes_per_trace": 100000,
"block_retention": "24h",
"compaction_window": "4h",
"per_tenant_override_config": "/etc/Overrides.yaml",
"per_tenant_override_period": "1m",
"metrics_generator_send_queue_size": 10,
"metrics_generator_send_workers": 1,
"max_search_duration": "5m"
}`
limitsYAML := LegacyOverrides{}
err := yaml.Unmarshal([]byte(inputYAML), &limitsYAML)
require.NoError(t, err, "expected to be able to unmarshal from YAML")
limitsJSON := LegacyOverrides{}
err = json.Unmarshal([]byte(inputJSON), &limitsJSON)
require.NoError(t, err, "expected to be able to unmarshal from JSON")
assert.Equal(t, limitsYAML, limitsJSON)
}
func TestConfig_legacy(t *testing.T) {
legacyRawYaml := `
ingestion_rate_strategy: local
ingestion_rate_limit_bytes: 12345
ingestion_burst_size_bytes: 67890
ingestion_tenant_shard_size: 3
ingestion_max_attribute_bytes: 1000
max_traces_per_user: 1
max_global_traces_per_user: 2
forwarders: ['foo']
metrics_generator_ring_size: 3
metrics_generator_processors: ['span-metrics']
metrics_generator_max_active_series: 4
metrics_generator_collection_interval: 5s
metrics_generator_disable_collection: false
metrics_generator_forwarder_queue_size: 6
metrics_generator_forwarder_workers: 7
metrics_generator_remote_write_headers:
tenant-id: foo
metrics_generator_processor_service_graphs_histogram_buckets: [1,2]
metrics_generator_processor_service_graphs_dimensions: ['foo']
metrics_generator_processor_service_graphs_peer_attributes: ['foo']
metrics_generator_processor_service_graphs_enable_client_server_prefix: false
metrics_generator_processor_service_graphs_enable_messaging_system_latency_histogram: false
metrics_generator_processor_span_metrics_histogram_buckets: [3,4]
metrics_generator_processor_span_metrics_dimensions: ['foo']
metrics_generator_processor_span_metrics_intrinsic_dimensions:
foo: true
metrics_generator_processor_span_metrics_filter_policies:
- include:
match_type: strict
attributes:
- key: foo
value: bar
metrics_generator_processor_span_metrics_dimension_mappings:
- name: 'foo'
source_labels:
- 'bar'
join: 'baz'
metrics_generator_processor_span_metrics_enable_target_info: true
metrics_generator_processor_local_blocks_max_live_traces: 8
metrics_generator_processor_local_blocks_max_block_duration: 9s
metrics_generator_processor_local_blocks_max_block_bytes: 10
metrics_generator_processor_local_blocks_flush_check_period: 11s
metrics_generator_processor_local_blocks_trace_idle_period: 12s
metrics_generator_processor_local_blocks_complete_block_timeout: 13s
metrics_generator_generate_native_histograms: true
metrics_generator_native_histogram_bucket_factor: 1.1
metrics_generator_native_histogram_max_bucket_number: 100
metrics_generator_native_histogram_min_reset_duration: 15m
block_retention: 14s
max_bytes_per_tag_values_query: 15
max_blocks_per_tag_values_query: 16
max_search_duration: 17s
max_bytes_per_trace: 18
per_tenant_override_config: /Overrides/Overrides.yaml
per_tenant_override_period: 19s
user_configurable_overrides:
enabled: true
`
legacyCfg := Config{}
legacyCfg.RegisterFlagsAndApplyDefaults(&flag.FlagSet{})
assert.NoError(t, yaml.UnmarshalStrict([]byte(legacyRawYaml), &legacyCfg))
assert.Equal(t, ConfigTypeLegacy, legacyCfg.ConfigType)
legacyCfg.ConfigType = ConfigTypeNew // For comparison vs new config
rawYaml := `
defaults:
ingestion:
rate_strategy: local
rate_limit_bytes: 12345
burst_size_bytes: 67890
max_traces_per_user: 1
max_global_traces_per_user: 2
tenant_shard_size: 3
max_attribute_bytes: 1000
read:
max_bytes_per_tag_values_query: 15
max_blocks_per_tag_values_query: 16
max_search_duration: 17s
compaction:
block_retention: 14s
metrics_generator:
ring_size: 3
processors:
- span-metrics
max_active_series: 4
collection_interval: 5s
disable_collection: false
remote_write_headers:
tenant-id: foo
forwarder:
queue_size: 6
workers: 7
generate_native_histograms: true
native_histogram_bucket_factor: 1.1
native_histogram_max_bucket_number: 100
native_histogram_min_reset_duration: 15m
processor:
service_graphs:
histogram_buckets:
- 1
- 2
dimensions:
- foo
peer_attributes:
- foo
enable_client_server_prefix: false
enable_messaging_system_latency_histogram: false
span_metrics:
histogram_buckets:
- 3
- 4
dimensions:
- foo
intrinsic_dimensions:
foo: true
filter_policies:
- include:
match_type: strict
attributes:
- key: foo
value: bar
dimension_mappings:
- name: foo
source_labels:
- bar
join: baz
enable_target_info: true
local_blocks:
max_live_traces: 8
max_block_duration: 9s
max_block_bytes: 10
flush_check_period: 11s
trace_idle_period: 12s
complete_block_timeout: 13s
forwarders:
- foo
global:
max_bytes_per_trace: 18
per_tenant_override_config: /Overrides/Overrides.yaml
per_tenant_override_period: 19s
user_configurable_overrides:
enabled: true
`
cfg := Config{}
cfg.RegisterFlagsAndApplyDefaults(&flag.FlagSet{})
assert.NoError(t, yaml.UnmarshalStrict([]byte(rawYaml), &cfg))
assert.Equal(t, cfg, legacyCfg)
}
func TestNumberOfOverrides(t *testing.T) {
// Asserts that the number of overrides in the new config is the same as the
// number of overrides in the legacy config.
assert.Equal(t, countOverrides(LegacyOverrides{}), countOverrides(Overrides{}))
}
// countOverrides recursively counts the number of non-struct fields in a struct.
func countOverrides(v any) int {
return rvCountFields(reflect.ValueOf(v))
}
func rvCountFields(rv reflect.Value) int {
if rv.Kind() != reflect.Struct {
return 0
}
n := 0
for i := 0; i < rv.NumField(); i++ {
fv := rv.Field(i)
if fv.Kind() == reflect.Struct {
n += rvCountFields(fv)
} else {
n++
}
}
return n
}
func TestOverrides_AssertUserConfigurableOverridesAreASubsetOfRuntimeOverrides(t *testing.T) {
userConfigurableOverrides := client.Limits{
Forwarders: &[]string{"test"},
CostAttribution: client.CostAttribution{
Dimensions: &map[string]string{"server": "192.168.1.1"},
},
MetricsGenerator: client.LimitsMetricsGenerator{
CollectionInterval: &client.Duration{Duration: 5 * time.Minute},
Processors: map[string]struct{}{"service-graphs": {}},
},
}
// TODO clear out collection_interval because unmarshalling a time.Duration into overrides.Overrides
// fails. The JSON decoder is not able to parse creations correctly, so e.g. a string like "30s" is
// not considered valid.
// To fix this we should migrate the various time.Duration to a similar type like client.Duration and
// verify they operate the same when marshalling/unmshalling yaml.
userConfigurableOverrides.MetricsGenerator.CollectionInterval = nil
// encode to json
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
err := encoder.Encode(&userConfigurableOverrides)
assert.NoError(t, err)
// and decode back to overrides.Overrides
d := json.NewDecoder(&buf)
// all fields should be known
d.DisallowUnknownFields()
var runtimeOverrides Overrides
err = d.Decode(&runtimeOverrides)
assert.NoError(t, err)
}
func TestFormatConversion(t *testing.T) {
legacyOverrides := generateTestLegacyOverrides()
// Verify that all fields have been populated in our test fixture
ensureAllFieldsPopulated(t, legacyOverrides)
// Convert to new format and back
newOverrides := legacyOverrides.toNewLimits()
convertedLegacyOverrides := newOverrides.toLegacy()
// Compare original and converted
assert.Equal(t, legacyOverrides, convertedLegacyOverrides)
}
// ensureAllFieldsPopulated checks that all fields in the struct have non-zero values
// This helps catch if a new field is added to LegacyOverrides but not included in our test fixture
func ensureAllFieldsPopulated(t *testing.T, o LegacyOverrides) {
v := reflect.ValueOf(o)
t.Helper()
// Get the type of the struct
structType := v.Type()
// Iterate through all fields
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := structType.Field(i).Name
// Skip certain fields that can be zero in valid configs
skip := []string{"IngestionArtificialDelay", "MetricsGeneratorSpanNameSanitization"}
if slices.Contains(skip, fieldName) {
continue
}
// For bool fields, we consider that they're explicitly set
// regardless of whether they're true or false
if field.Kind() == reflect.Bool {
continue
}
assert.False(t, isZeroValue(field), "Field %s has not been populated in the test fixture - add a value for it", fieldName)
}
}
// isZeroValue checks if a reflect.Value is the zero value for its type
func isZeroValue(v reflect.Value) bool {
// Handle nil interfaces and pointers
if (v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr) && v.IsNil() {
return true
}
// Special case for slices and maps
if (v.Kind() == reflect.Slice || v.Kind() == reflect.Map) && v.Len() == 0 {
return true
}
// For structs, recursively check fields
if v.Kind() == reflect.Struct {
if v.NumField() == 0 {
return true
}
allZero := true
for i := 0; i < v.NumField(); i++ {
if !isZeroValue(v.Field(i)) {
allZero = false
break
}
}
return allZero
}
// For other types, compare with zero value of that type
zeroValue := reflect.Zero(v.Type()).Interface()
return reflect.DeepEqual(v.Interface(), zeroValue)
}
// generateTestLegacyOverrides creates a test fixture with predefined values
// If a new field is added to LegacyOverrides, it must be added here as well,
// or the ensureAllFieldsPopulated check will fail.
func generateTestLegacyOverrides() LegacyOverrides {
// Create a predefined test fixture with values for all fields
return LegacyOverrides{
IngestionRateStrategy: "local",
IngestionRateLimitBytes: 100,
IngestionBurstSizeBytes: 200,
IngestionTenantShardSize: 3,
IngestionMaxAttributeBytes: 1000,
IngestionArtificialDelay: durationPtr(5 * time.Minute),
IngestionRetryInfoEnabled: true,
MaxLocalTracesPerUser: 1000,
MaxGlobalTracesPerUser: 2000,
Forwarders: []string{"forwarder-1", "forwarder-2"},
MetricsGeneratorRingSize: 3,
MetricsGeneratorProcessors: makeListToMap([]string{"processor-1", "processor-2"}),
MetricsGeneratorMaxActiveSeries: 1000,
MetricsGeneratorMaxActiveEntities: 100,
MetricsGeneratorMaxCardinalityPerLabel: 500,
MetricsGeneratorCollectionInterval: 10 * time.Second,
MetricsGeneratorDisableCollection: false,
MetricsGeneratorGenerateNativeHistograms: histograms.HistogramMethodNative,
MetricsGeneratorTraceIDLabelName: "trace_id",
MetricsGeneratorForwarderQueueSize: 100,
MetricsGeneratorForwarderWorkers: 5,
MetricsGeneratorRemoteWriteHeaders: RemoteWriteHeaders{"header-1": "value-1"},
MetricsGeneratorProcessorServiceGraphsHistogramBuckets: []float64{1.0, 2.0, 5.0},
MetricsGeneratorProcessorServiceGraphsDimensions: []string{"dimension-1", "dimension-2"},
MetricsGeneratorProcessorServiceGraphsPeerAttributes: []string{"attribute-1", "attribute-2"},
MetricsGeneratorProcessorServiceGraphsFilterPolicies: []filterconfig.FilterPolicy{{Exclude: &filterconfig.PolicyMatch{MatchType: "strict", Attributes: []filterconfig.MatchPolicyAttribute{{Key: "resource.service.name", Value: "my-service"}}}}},
MetricsGeneratorProcessorServiceGraphsEnableClientServerPrefix: boolPtr(true),
MetricsGeneratorProcessorServiceGraphsEnableMessagingSystemLatencyHistogram: boolPtr(true),
MetricsGeneratorProcessorServiceGraphsEnableVirtualNodeLabel: boolPtr(true),
MetricsGeneratorProcessorServiceGraphsSpanMultiplierKey: "custom_key",
MetricsGeneratorProcessorSpanMetricsHistogramBuckets: []float64{1.0, 2.0, 5.0},
MetricsGeneratorProcessorSpanMetricsDimensions: []string{"dimension-1", "dimension-2"},
MetricsGeneratorProcessorSpanMetricsIntrinsicDimensions: map[string]bool{"dim-1": true, "dim-2": false},
MetricsGeneratorProcessorSpanMetricsFilterPolicies: []filterconfig.FilterPolicy{
{
Include: &filterconfig.PolicyMatch{
MatchType: "strict",
Attributes: []filterconfig.MatchPolicyAttribute{
{Key: "key-1", Value: "value-1"},
},
},
Exclude: &filterconfig.PolicyMatch{
MatchType: "strict",
Attributes: []filterconfig.MatchPolicyAttribute{
{Key: "key-2", Value: "value-2"},
},
},
},
},
MetricsGeneratorProcessorSpanMetricsDimensionMappings: []sharedconfig.DimensionMappings{
{
Name: "mapping-1",
SourceLabel: []string{"source-label-1"},
Join: "join-1",
},
},
MetricsGeneratorProcessorSpanMetricsEnableTargetInfo: boolPtr(true),
MetricsGeneratorProcessorSpanMetricsTargetInfoExcludedDimensions: []string{"excluded-dim-1", "excluded-dim-2"},
MetricsGeneratorProcessorSpanMetricsEnableInstanceLabel: boolPtr(false),
MetricsGeneratorProcessorSpanMetricsSpanMultiplierKey: "custom_key",
MetricsGeneratorProcessorLocalBlocksMaxLiveTraces: 100,
MetricsGeneratorProcessorLocalBlocksMaxBlockDuration: 10 * time.Minute,
MetricsGeneratorProcessorLocalBlocksMaxBlockBytes: 1024 * 1024,
MetricsGeneratorProcessorLocalBlocksFlushCheckPeriod: 30 * time.Second,
MetricsGeneratorProcessorLocalBlocksTraceIdlePeriod: 5 * time.Minute,
MetricsGeneratorProcessorLocalBlocksCompleteBlockTimeout: 30 * time.Minute,
MetricsGeneratorProcessorHostInfoHostIdentifiers: []string{"host-id-1", "host-id-2"},
MetricsGeneratorProcessorHostInfoMetricName: "host_info",
MetricsGeneratorIngestionSlack: 1 * time.Minute,
MetricsGeneratorNativeHistogramBucketFactor: 1.5,
MetricsGeneratorNativeHistogramMaxBucketNumber: 200,
MetricsGeneratorNativeHistogramMinResetDuration: 10 * time.Minute,
MetricsGeneratorSpanNameSanitization: "",
BlockRetention: model.Duration(7 * 24 * time.Hour),
CompactionDisabled: true,
CompactionWindow: model.Duration(4 * time.Hour),
MaxBytesPerTagValuesQuery: 1000,
MaxBlocksPerTagValuesQuery: 100,
MaxSearchDuration: model.Duration(10 * time.Minute),
MaxMetricsDuration: model.Duration(30 * time.Minute),
UnsafeQueryHints: true,
MaxBytesPerTrace: 10 * 1024 * 1024,
CostAttribution: CostAttributionOverrides{
MaxCardinality: 1000,
Dimensions: map[string]string{"dim-1": "value-1", "dim-2": "value-2"},
},
DedicatedColumns: backend.DedicatedColumns{
{
Scope: backend.DedicatedColumnScopeResource,
Name: "resource-column",
Type: backend.DedicatedColumnTypeString,
},
{
Scope: backend.DedicatedColumnScopeSpan,
Name: "span-column",
Type: backend.DedicatedColumnTypeString,
},
},
}
}
// Helper function to create a duration pointer
func durationPtr(d time.Duration) *time.Duration {
return &d
}
// Helper function to create ListToMap
func makeListToMap(items []string) listtomap.ListToMap {
result := make(listtomap.ListToMap)
for _, item := range items {
result[item] = struct{}{}
}
return result
}