diff --git a/connector/topologyconnector/expression_ref_manager.go b/connector/topologyconnector/expression_ref_manager.go new file mode 100644 index 0000000..f368aad --- /dev/null +++ b/connector/topologyconnector/expression_ref_manager.go @@ -0,0 +1,364 @@ +package topologyconnector + +import ( + "sort" + "sync" + + "github.com/google/cel-go/cel" + "github.com/stackvista/sts-opentelemetry-collector/connector/topologyconnector/internal" + "github.com/stackvista/sts-opentelemetry-collector/connector/topologyconnector/types" + stsSettingsModel "github.com/stackvista/sts-opentelemetry-collector/extension/settingsproviderextension/generated/settings" + "go.uber.org/zap" +) + +// ExpressionRefManager should perform best-effort extraction of referenced variables +// and attribute keys from mapping expressions. +// +// All errors during expression parsing or type-checking should intentionally be ignored. +// Missing or invalid expressions simply result in fewer extracted references. +// +// Note: Mappings that only reference resource/scope do not produce ExpressionRefSummaries, +// as those are always hashed unconditionally during deduplication. +type ExpressionRefManager interface { + Update( + signals []stsSettingsModel.OtelInputSignal, + componentMappings map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping, + relationMappings map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelRelationMapping, + ) + + Current(signal stsSettingsModel.OtelInputSignal) map[string]*types.ExpressionRefSummary +} + +type DefaultExpressionRefManager struct { + logger *zap.Logger + evaluator internal.ExpressionEvaluator + + mu sync.RWMutex + // signal -> mappingIdentifier -> summary + expressionRefSummaries map[stsSettingsModel.OtelInputSignal]map[string]*types.ExpressionRefSummary +} + +func NewExpressionRefManager( + logger *zap.Logger, + evaluator internal.ExpressionEvaluator, +) *DefaultExpressionRefManager { + return &DefaultExpressionRefManager{ + logger: logger, + evaluator: evaluator, + expressionRefSummaries: make( + map[stsSettingsModel.OtelInputSignal]map[string]*types.ExpressionRefSummary, + ), + } +} + +// Update walks the CEL ASTs of mapping expressions to precompute referenced vars and attribute keys used by mappings +// for each signal. +func (p *DefaultExpressionRefManager) Update( + signals []stsSettingsModel.OtelInputSignal, + componentMappings map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping, + relationMappings map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelRelationMapping, +) { + p.logger.Debug("ExpressionRefManager processing snapshot update", + zap.Int("signal_count", len(signals))) + + summariesBySignalUpdate := make(map[stsSettingsModel.OtelInputSignal]map[string]*types.ExpressionRefSummary) + + for _, sig := range signals { + summariesBySignal := make(map[string]*types.ExpressionRefSummary) + + for _, cm := range componentMappings[sig] { + refSummary := p.collectRefsForComponent(&cm) + if refSummary != nil { + summariesBySignal[cm.GetIdentifier()] = refSummary + } + } + for _, rm := range relationMappings[sig] { + refSummary := p.collectRefsForRelation(&rm) + if refSummary != nil { + summariesBySignal[rm.GetIdentifier()] = refSummary + } + } + + if len(summariesBySignal) > 0 { + summariesBySignalUpdate[sig] = summariesBySignal + } + } + + p.mu.Lock() + p.expressionRefSummaries = summariesBySignalUpdate + p.mu.Unlock() + + p.logger.Debug("ExpressionRefManager update complete", + zap.Int("total_refs", p.countTotalRefs(summariesBySignalUpdate))) +} + +func (p *DefaultExpressionRefManager) countTotalRefs( + refs map[stsSettingsModel.OtelInputSignal]map[string]*types.ExpressionRefSummary, +) int { + count := 0 + for _, m := range refs { + count += len(m) + } + return count +} + +func (p *DefaultExpressionRefManager) Current( + signal stsSettingsModel.OtelInputSignal, +) map[string]*types.ExpressionRefSummary { + p.mu.RLock() + defer p.mu.RUnlock() + return p.expressionRefSummaries[signal] +} + +func (p *DefaultExpressionRefManager) collectRefsForComponent( + m *stsSettingsModel.OtelComponentMapping, +) *types.ExpressionRefSummary { + agg := newExpressionRefAggregator(p.logger) + + // input not being walked - it's already processed at this point (via the signal traverser/visitor) + + // variables + if m.Vars != nil { + for _, v := range *m.Vars { + agg.walkAny(p.evaluator, v.Value) + } + } + + // core outputs + agg.walkString(p.evaluator, m.Output.Identifier) + agg.walkString(p.evaluator, m.Output.Name) + agg.walkString(p.evaluator, m.Output.TypeName) + agg.walkOptionalString(p.evaluator, m.Output.TypeIdentifier) + agg.walkString(p.evaluator, m.Output.LayerName) + agg.walkOptionalString(p.evaluator, m.Output.LayerIdentifier) + agg.walkString(p.evaluator, m.Output.DomainName) + agg.walkOptionalString(p.evaluator, m.Output.DomainIdentifier) + p.collectRefsForComponentFieldMapping(agg, m.Output.Optional) + p.collectRefsForComponentFieldMapping(agg, m.Output.Required) + + return agg.toSummary() +} + +func (p *DefaultExpressionRefManager) collectRefsForComponentFieldMapping( + agg *expressionRefAggregator, + componentFieldMapping *stsSettingsModel.OtelComponentMappingFieldMapping, +) { + if componentFieldMapping != nil { + if componentFieldMapping.AdditionalIdentifiers != nil { + for _, e := range *componentFieldMapping.AdditionalIdentifiers { + agg.walkString(p.evaluator, e) + } + } + if componentFieldMapping.Tags != nil { + for _, tm := range *componentFieldMapping.Tags { + agg.walkAny(p.evaluator, tm.Source) + } + } + if componentFieldMapping.Version != nil { + agg.walkOptionalString(p.evaluator, componentFieldMapping.Version) + } + } +} + +func (p *DefaultExpressionRefManager) collectRefsForRelation( + m *stsSettingsModel.OtelRelationMapping, +) *types.ExpressionRefSummary { + agg := newExpressionRefAggregator(p.logger) + + // input not being walked - it's already processed at this point (via the signal traverser/visitor) + + // variables + if m.Vars != nil { + for _, v := range *m.Vars { + agg.walkAny(p.evaluator, v.Value) + } + } + + // outputs + agg.walkString(p.evaluator, m.Output.SourceId) + agg.walkString(p.evaluator, m.Output.TargetId) + agg.walkString(p.evaluator, m.Output.TypeName) + agg.walkOptionalString(p.evaluator, m.Output.TypeIdentifier) + + return agg.toSummary() +} + +// expressionRefAggregator accumulates references using the ExpressionAstWalker and reduces them +// to ExpressionRefSummary. + +type entityFieldSelector struct { + // attributes["*"] – hash the entire attribute map + allAttributes bool + + // attributes["key"] + attributeKeys map[string]struct{} + + // top-level fields (e.g. span.name, metric.unit) + fieldKeys map[string]struct{} +} + +type expressionRefAggregator struct { + logger *zap.Logger + + datapoint entityFieldSelector + span entityFieldSelector + metric entityFieldSelector + + hasValidExpr bool +} + +func newExpressionRefAggregator(logger *zap.Logger) *expressionRefAggregator { + return &expressionRefAggregator{ + logger: logger, + datapoint: entityFieldSelector{ + attributeKeys: make(map[string]struct{}), + fieldKeys: make(map[string]struct{}), + }, + span: entityFieldSelector{ + attributeKeys: make(map[string]struct{}), + fieldKeys: make(map[string]struct{}), + }, + metric: entityFieldSelector{ + attributeKeys: make(map[string]struct{}), + fieldKeys: make(map[string]struct{}), + }, + hasValidExpr: false, + } +} + +func (r *expressionRefAggregator) walkString( + eval internal.ExpressionEvaluator, + expr stsSettingsModel.OtelStringExpression, +) { + astRes, err := eval.GetStringExpressionAST(expr) + if err != nil || astRes == nil || astRes.CheckedAST == nil { + return + } + r.hasValidExpr = true + r.walkAST(astRes.CheckedAST) +} + +func (r *expressionRefAggregator) walkOptionalString( + eval internal.ExpressionEvaluator, + expr *stsSettingsModel.OtelStringExpression, +) { + if expr == nil { + return + } + r.walkString(eval, *expr) +} + +func (r *expressionRefAggregator) walkAny( + eval internal.ExpressionEvaluator, + expr stsSettingsModel.OtelAnyExpression, +) { + astRes, err := eval.GetAnyExpressionAST(expr) + if err != nil || astRes == nil || astRes.CheckedAST == nil { + return + } + r.hasValidExpr = true + r.walkAST(astRes.CheckedAST) +} + +// walkAST processes a checked CEL AST and accumulates attribute references +// into the corresponding sets (datapointAttrFilter, spanAttrFilter, metricAttrFilter). +// +// Current behavior: +// - "datapoint" root: adds keys from datapoint.attributes +// - "span" root: adds keys from span.attributes +// - "metric" root: adds keys from metric.attributes +// - "resource" and "scope" roots are ignored because they are fully included +// +// IMPORTANT: If a new type of input is added (e.g., logs, events) or a new root +// is introduced in the mapping expressions, this function must be extended +// to correctly accumulate references for deduplication purposes. +// +// Also, if additional roots are supported, make sure to update: +// - types.ExpressionRefSummary +// - expressionRefAggregator.toSummary() +// - collectRefsForComponent / collectRefsForRelation +func (r *expressionRefAggregator) walkAST(checked *cel.Ast) { + walker := internal.NewExpressionAstWalker() + walker.Walk(checked.NativeRep().Expr()) + + for _, ref := range walker.GetReferences() { + switch ref.Root { + case "datapoint": + r.walkAttributeRef(&r.datapoint, ref) + + case "span": + r.walkAttributeRef(&r.span, ref) + + case "metric": + r.walkAttributeRef(&r.metric, ref) + + case "resource", "scope", "vars": + // resource & scope are always fully included + // vars resolve to datapoint/span/metric data + + default: + r.logger.Debug( + "Unknown expression ref root detected; consider updating walkAST and toSummary", + zap.String("root", ref.Root), + ) + } + } +} + +func (r *expressionRefAggregator) walkAttributeRef( + sel *entityFieldSelector, + ref internal.Reference, +) { + if sel.allAttributes { + return + } + + // attributes + if len(ref.Path) == 1 && ref.Path[0] == "attributes" { + sel.allAttributes = true + sel.attributeKeys = nil + return + } + + // top-level fields (span.name, metric.unit, etc.) + if len(ref.Path) == 1 { + sel.fieldKeys[ref.Path[0]] = struct{}{} + } + + // attributes["key"] + if len(ref.Path) >= 2 && ref.Path[0] == "attributes" { + sel.attributeKeys[ref.Path[1]] = struct{}{} + } +} + +func (r *expressionRefAggregator) toSummary() *types.ExpressionRefSummary { + if !r.hasValidExpr { + return nil + } + + return types.NewExpressionRefSummary( + selectorToSummary(r.datapoint), + selectorToSummary(r.span), + selectorToSummary(r.metric), + ) +} + +func selectorToSummary(sel entityFieldSelector) types.EntityRefSummary { + return types.NewEntityRefSummary( + sel.allAttributes, + setKeys(sel.attributeKeys), + setKeys(sel.fieldKeys), + ) +} + +func setKeys(m map[string]struct{}) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/connector/topologyconnector/expression_ref_manager_test.go b/connector/topologyconnector/expression_ref_manager_test.go new file mode 100644 index 0000000..dcf131e --- /dev/null +++ b/connector/topologyconnector/expression_ref_manager_test.go @@ -0,0 +1,465 @@ +package topologyconnector_test + +import ( + "context" + "reflect" + sort "sort" + "testing" + + topologyConnector "github.com/stackvista/sts-opentelemetry-collector/connector/topologyconnector" + "github.com/stackvista/sts-opentelemetry-collector/connector/topologyconnector/internal" + "github.com/stackvista/sts-opentelemetry-collector/connector/topologyconnector/metrics" + stsSettingsModel "github.com/stackvista/sts-opentelemetry-collector/extension/settingsproviderextension/generated/settings" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component/componenttest" + "go.uber.org/zap/zaptest" +) + +/* +Expected*Fields document which model fields are intentionally walked (and skipped) by the +expression reference collectors (collectRefsForComponent / collectRefsForRelation). + +These lists are enforced by reflection-based tests to ensure that when new fields +are added to the mapping models, they are either: + - explicitly walked, or + - consciously documented as intentionally skipped. + +This acts as a safety net against silently missing new expression-bearing fields +during schema evolution. +*/ + +//nolint:gochecknoglobals +var ExpectedComponentMappingFields = struct { + TopLevelWalked []string + TopLevelSkipped []string + Output []string + FieldMapping []string +}{ + // Top-level fields of OtelComponentMapping that are walked + TopLevelWalked: []string{ + "Vars", + "Output", + }, + + // Fields deliberately NOT walked by collectRefsForComponent + TopLevelSkipped: []string{ + "CreatedTimeStamp", + "ExpireAfterMs", + "Id", + "Identifier", + "Input", + "Name", + "Shard", + "Type", + }, + + // Fields of OtelComponentMappingOutput that are walked + Output: []string{ + "Identifier", + "Name", + "TypeName", + "TypeIdentifier", + "LayerName", + "LayerIdentifier", + "DomainName", + "DomainIdentifier", + "Optional", + "Required", + }, + + // Fields of OtelComponentMappingFieldMapping (Optional / Required) + FieldMapping: []string{ + "AdditionalIdentifiers", + "Tags", + "Version", + }, +} + +//nolint:gochecknoglobals +var ExpectedRelationMappingFields = struct { + TopLevelWalked []string + TopLevelSkipped []string + Output []string +}{ + // Top-level fields of OtelRelationMapping that are walked + TopLevelWalked: []string{ + "Vars", + "Output", + }, + + // Fields deliberately NOT walked by collectRefsForRelation + TopLevelSkipped: []string{ + "CreatedTimeStamp", + "ExpireAfterMs", + "Id", + "Identifier", + "Input", + "Name", + "Shard", + "Type", + }, + + // Fields of OtelRelationMappingOutput that are walked + Output: []string{ + "SourceId", + "TargetId", + "TypeName", + "TypeIdentifier", + }, +} + +func TestExpressionRefManager_UpdateAndCurrent_ComponentAndRelation(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + // Build a component mapping with vars and outputs referencing various inputs + comp := stsSettingsModel.OtelComponentMapping{ + Identifier: "comp-1", + Input: stsSettingsModel.OtelInput{ + Signal: stsSettingsModel.OtelInputSignalList{stsSettingsModel.METRICS}, + }, + Output: stsSettingsModel.OtelComponentMappingOutput{ + Identifier: sExpr("id-${resource.attributes['service.name']}-${vars.ns}"), + Name: sExpr("name-${vars.ns}"), + TypeName: sExpr("type"), + LayerName: sExpr("layer-${datapoint.attributes['kind']}"), + + Required: &stsSettingsModel.OtelComponentMappingFieldMapping{ + Tags: &[]stsSettingsModel.OtelTagMapping{ + { + Source: aExpr("${span.attributes}"), + Pattern: ptr("service.\\(.*)"), + Target: "service.${1}", + }, + }, + }, + }, + Vars: &[]stsSettingsModel.OtelVariableMapping{ + {Name: "ns", Value: aExpr("${span.name}")}, + }, + } + + // Relation mapping referencing span attributes + rel := stsSettingsModel.OtelRelationMapping{ + Identifier: "rel-1", + Input: stsSettingsModel.OtelInput{ + Signal: stsSettingsModel.OtelInputSignalList{stsSettingsModel.TRACES}, + }, + Output: stsSettingsModel.OtelRelationMappingOutput{ + SourceId: sExpr("${span.attributes['src']}"), + TargetId: sExpr("${span.attributes['dst']}"), + TypeName: sExpr("rel"), + }, + } + + signals := []stsSettingsModel.OtelInputSignal{stsSettingsModel.METRICS, stsSettingsModel.TRACES} + compBySig := map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping{ + stsSettingsModel.METRICS: {comp}, + stsSettingsModel.TRACES: {}, + } + relBySig := map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelRelationMapping{ + stsSettingsModel.METRICS: {}, + stsSettingsModel.TRACES: {rel}, + } + + // Exercise Update and then verify Current for each signal + refManager.Update(signals, compBySig, relBySig) + + // Metrics signal -> component expressionRefSummaries + m := refManager.Current(stsSettingsModel.METRICS) + require.NotNil(t, m) + compRefs, ok := m["comp-1"] + require.True(t, ok) + require.ElementsMatch(t, []string{"kind"}, compRefs.Datapoint.AttributeKeys) + require.True(t, compRefs.Span.AllAttributes) + require.ElementsMatch(t, []string{"name"}, compRefs.Span.FieldKeys) + // resource and scope are implicitly included in projection; we only assert tracked keys here + + // Traces signal -> relation expressionRefSummaries + tr := refManager.Current(stsSettingsModel.TRACES) + require.NotNil(t, tr) + relRefs, ok := tr["rel-1"] + require.True(t, ok) + require.ElementsMatch(t, []string{"src", "dst"}, relRefs.Span.AttributeKeys) +} + +// Verify that a mapping without datapoint, span or metric expressions still returns an "empty" ExpressionRefSummary +func TestExpressionRefManager_UpdateAndCurrent_ComponentWithResourceOnlyExpressions(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + // Build a component mapping with vars and outputs referencing various inputs + comp := stsSettingsModel.OtelComponentMapping{ + Identifier: "comp-1", + Input: stsSettingsModel.OtelInput{ + Signal: stsSettingsModel.OtelInputSignalList{stsSettingsModel.METRICS}, + }, + Output: stsSettingsModel.OtelComponentMappingOutput{ + Identifier: sExpr("id-${resource.attributes['service.name']}"), + Name: sExpr("name"), + TypeName: sExpr("type"), + LayerName: sExpr("layer"), + }, + } + + signals := []stsSettingsModel.OtelInputSignal{stsSettingsModel.METRICS} + compBySig := map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping{ + stsSettingsModel.METRICS: {comp}, + } + relBySig := make(map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelRelationMapping) + + refManager.Update(signals, compBySig, relBySig) + + m := refManager.Current(stsSettingsModel.METRICS) + require.NotNil(t, m) + compRefs, ok := m["comp-1"] + require.True(t, ok) + require.Empty(t, compRefs.Datapoint) + require.Empty(t, compRefs.Span) + require.Empty(t, compRefs.Metric) +} + +func TestExpressionRefManager_Current_NilForUnknownSignal(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + // No update yet + cur := refManager.Current(stsSettingsModel.TRACES) + require.Nil(t, cur) +} + +func TestExpressionRefManager_InvalidExpressionsAreIgnored(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + comp := stsSettingsModel.OtelComponentMapping{ + Identifier: "bad-comp", + Input: stsSettingsModel.OtelInput{ + Signal: stsSettingsModel.OtelInputSignalList{stsSettingsModel.METRICS}, + }, + Output: stsSettingsModel.OtelComponentMappingOutput{ + Identifier: sExpr("${this is not valid CEL"), + Name: sExpr("${also bad"), + }, + } + + refManager.Update( + []stsSettingsModel.OtelInputSignal{stsSettingsModel.METRICS}, + map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping{ + stsSettingsModel.METRICS: {comp}, + }, + nil, + ) + + cur := refManager.Current(stsSettingsModel.METRICS) + require.Nil(t, cur, "invalid expressions should produce no summaries") +} + +func TestExpressionRefManager_OptionalFieldsAreLenient(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + comp := stsSettingsModel.OtelComponentMapping{ + Identifier: "comp", + Input: stsSettingsModel.OtelInput{ + Signal: stsSettingsModel.OtelInputSignalList{stsSettingsModel.METRICS}, + }, + Output: stsSettingsModel.OtelComponentMappingOutput{ + Identifier: sExpr("${resource.attributes['ok']}"), + Optional: &stsSettingsModel.OtelComponentMappingFieldMapping{ + AdditionalIdentifiers: &[]stsSettingsModel.OtelStringExpression{sExpr("${invalid")}, + }, + }, + Vars: &[]stsSettingsModel.OtelVariableMapping{ + {Name: "ns", Value: aExpr("${span.attributes['ns']}")}, + }, + } + + refManager.Update( + []stsSettingsModel.OtelInputSignal{stsSettingsModel.METRICS}, + map[stsSettingsModel.OtelInputSignal][]stsSettingsModel.OtelComponentMapping{ + stsSettingsModel.METRICS: {comp}, + }, + nil, + ) + + cur := refManager.Current(stsSettingsModel.METRICS) + require.NotNil(t, cur) + compRefs, ok := cur["comp"] + require.True(t, ok) + require.ElementsMatch(t, []string{"ns"}, compRefs.Span.AttributeKeys) +} + +func TestExpressionRefManager_UpdateReplacesState(t *testing.T) { + eval := newTestCELEvaluator(t) + logger := zaptest.NewLogger(t) + refManager := topologyConnector.NewExpressionRefManager(logger, eval) + + refManager.Update( + []stsSettingsModel.OtelInputSignal{stsSettingsModel.METRICS}, + nil, + nil, + ) + + refManager.Update( + []stsSettingsModel.OtelInputSignal{}, + nil, + nil, + ) + + require.Nil(t, refManager.Current(stsSettingsModel.METRICS)) +} + +func TestComponentMappingFieldsCoverage(t *testing.T) { + t.Run("OtelComponentMapping top-level coverage", func(t *testing.T) { + assertStructFieldCoverage( + t, + "OtelComponentMapping", + reflect.TypeOf(stsSettingsModel.OtelComponentMapping{}), + append( + ExpectedComponentMappingFields.TopLevelWalked, + ExpectedComponentMappingFields.TopLevelSkipped..., + ), + "Add walking logic in collectRefsForComponent or document why this field is intentionally skipped.", + ) + }) + + t.Run("OtelComponentMappingOutput coverage", func(t *testing.T) { + assertStructFieldCoverage( + t, + "OtelComponentMappingOutput", + reflect.TypeOf(stsSettingsModel.OtelComponentMappingOutput{}), + ExpectedComponentMappingFields.Output, + "Add walking logic in collectRefsForComponent and update ExpectedComponentMappingFields.Output,\n"+ + "or document why this field should be intentionally skipped.", + ) + }) + + t.Run("OtelComponentMappingFieldMapping coverage", func(t *testing.T) { + assertStructFieldCoverage( + t, + "OtelComponentMappingFieldMapping", + reflect.TypeOf(stsSettingsModel.OtelComponentMappingFieldMapping{}), + ExpectedComponentMappingFields.FieldMapping, + "Add walking logic for Optional/Required fields in collectRefsForComponent\n"+ + "or document why this field should be intentionally skipped.", + ) + }) +} + +func TestRelationMappingFieldsCoverage(t *testing.T) { + t.Run("OtelRelationMapping top-level coverage", func(t *testing.T) { + assertStructFieldCoverage( + t, + "OtelRelationMapping", + reflect.TypeOf(stsSettingsModel.OtelRelationMapping{}), + append( + ExpectedRelationMappingFields.TopLevelWalked, + ExpectedRelationMappingFields.TopLevelSkipped..., + ), + "Add walking logic in collectRefsForRelation or document why this field is intentionally skipped.", + ) + }) + + assertStructFieldCoverage( + t, + "OtelRelationMappingOutput", + reflect.TypeOf(stsSettingsModel.OtelRelationMappingOutput{}), + ExpectedRelationMappingFields.Output, + "Add walking logic in collectRefsForRelation or update the expected field list.", + ) +} + +func newTestCELEvaluator(t *testing.T) internal.ExpressionEvaluator { + t.Helper() + eval, err := internal.NewCELEvaluator( + context.Background(), + metrics.MeteredCacheSettings{ + Name: "expression_cache_test", + EnableMetrics: false, + TelemetrySettings: componenttest.NewNopTelemetrySettings(), + }, + ) + require.NoError(t, err) + return eval +} + +func assertStructFieldCoverage( + t *testing.T, + structName string, + structType reflect.Type, + expectedFields []string, + contextHint string, +) { + t.Helper() + + actualFields := getStructFieldNames(structType) + + expected := make(map[string]struct{}, len(expectedFields)) + for _, f := range expectedFields { + expected[f] = struct{}{} + } + + actual := make(map[string]struct{}, len(actualFields)) + for _, f := range actualFields { + actual[f] = struct{}{} + } + + var missing []string + for field := range actual { + if _, ok := expected[field]; !ok { + missing = append(missing, field) + } + } + + var obsolete []string + for field := range expected { + if _, ok := actual[field]; !ok { + obsolete = append(obsolete, field) + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf( + "New fields detected in %s that are not covered by expression reference walking: %v\n%s", + structName, + missing, + contextHint, + ) + } + + if len(obsolete) > 0 { + sort.Strings(obsolete) + t.Errorf( + "Fields documented for %s no longer exist: %v\nUpdate the test expectations.", + structName, + obsolete, + ) + } +} + +func getStructFieldNames(t reflect.Type) []string { + require.Equal(nil, t.Kind(), reflect.Struct) + + var fields []string + for i := 0; i < t.NumField(); i++ { + fields = append(fields, t.Field(i).Name) + } + return fields +} + +func sExpr(s string) stsSettingsModel.OtelStringExpression { + return stsSettingsModel.OtelStringExpression{Expression: s} +} + +func aExpr(s string) stsSettingsModel.OtelAnyExpression { + return stsSettingsModel.OtelAnyExpression{Expression: s} +} + +func ptr[T any](v T) *T { return &v } diff --git a/connector/topologyconnector/internal/eval_test.go b/connector/topologyconnector/internal/eval_test.go index 2ee16f8..77f9383 100644 --- a/connector/topologyconnector/internal/eval_test.go +++ b/connector/topologyconnector/internal/eval_test.go @@ -51,11 +51,6 @@ func (f *mockEvalExpressionEvaluator) GetStringExpressionAST(_ settings.OtelStri return nil, nil } -func (f *mockEvalExpressionEvaluator) GetOptionalStringExpressionAST(_ *settings.OtelStringExpression) (*GetASTResult, error) { - //nolint:nilnil - return nil, nil -} - func (f *mockEvalExpressionEvaluator) GetBooleanExpressionAST(_ settings.OtelBooleanExpression) (*GetASTResult, error) { //nolint:nilnil return nil, nil diff --git a/connector/topologyconnector/internal/expression.go b/connector/topologyconnector/internal/expression.go index 221d135..7526187 100644 --- a/connector/topologyconnector/internal/expression.go +++ b/connector/topologyconnector/internal/expression.go @@ -35,7 +35,6 @@ type ExpressionEvaluator interface { // GetStringExpressionAST returns the parsed AST of a String expression without evaluating it. GetStringExpressionAST(expr settings.OtelStringExpression) (*GetASTResult, error) - GetOptionalStringExpressionAST(expr *settings.OtelStringExpression) (*GetASTResult, error) // GetBooleanExpressionAST returns the parsed AST of a Boolean expression without evaluation. GetBooleanExpressionAST(expr settings.OtelBooleanExpression) (*GetASTResult, error) // GetMapExpressionAST returns the parsed AST of a Map expression without evaluating it. @@ -249,19 +248,6 @@ func (e *CelEvaluator) GetStringExpressionAST(expr settings.OtelStringExpression return &GetASTResult{CheckedAST: ast}, nil } -func (e *CelEvaluator) GetOptionalStringExpressionAST(expr *settings.OtelStringExpression) (*GetASTResult, error) { - if expr == nil { - //nolint:nilnil - return nil, nil - } - - result, err := e.GetStringExpressionAST(*expr) - if err != nil { - return nil, err - } - return result, nil -} - func (e *CelEvaluator) GetBooleanExpressionAST(expr settings.OtelBooleanExpression) (*GetASTResult, error) { ast, err := e.astOrCached(expr.Expression, BooleanType, rewriteIdentityExpression) if err != nil { diff --git a/connector/topologyconnector/internal/mapping_handler_test.go b/connector/topologyconnector/internal/mapping_handler_test.go index 450d1e5..8a87ef7 100644 --- a/connector/topologyconnector/internal/mapping_handler_test.go +++ b/connector/topologyconnector/internal/mapping_handler_test.go @@ -40,11 +40,6 @@ func (m *mockEvaluator) GetStringExpressionAST(_ settings.OtelStringExpression) return nil, nil } -func (m *mockEvaluator) GetOptionalStringExpressionAST(_ *settings.OtelStringExpression) (*internal.GetASTResult, error) { - //nolint:nilnil - return nil, nil -} - func (m *mockEvaluator) GetBooleanExpressionAST(_ settings.OtelBooleanExpression) (*internal.GetASTResult, error) { //nolint:nilnil return nil, nil diff --git a/connector/topologyconnector/types/expression_ref_summary.go b/connector/topologyconnector/types/expression_ref_summary.go new file mode 100644 index 0000000..c58db45 --- /dev/null +++ b/connector/topologyconnector/types/expression_ref_summary.go @@ -0,0 +1,61 @@ +//nolint:revive +package types + +import "sort" + +type EntityRefSummary struct { + // Hash all attributes (attributes) + AllAttributes bool + + // Hash only these attribute keys (attributes["key"]) + AttributeKeys []string + + // Hash these top-level entity fields (span.name, metric.unit, etc.) + FieldKeys []string +} + +func NewEntityRefSummary(allAttrs bool, attrKeys, fieldKeys []string) EntityRefSummary { + sort.Strings(attrKeys) + sort.Strings(fieldKeys) + + return EntityRefSummary{ + AllAttributes: allAttrs, + AttributeKeys: attrKeys, + FieldKeys: fieldKeys, + } +} + +// ExpressionRefSummary summarizes which inputs to include in the projection for a mapping. +// Resource attributes and Scope fields are always included entirely. +type ExpressionRefSummary struct { + Datapoint EntityRefSummary + Span EntityRefSummary + Metric EntityRefSummary +} + +// NewExpressionRefSummary Constructor ensures all slices are sorted deterministically +func NewExpressionRefSummary(datapoint, span, metric EntityRefSummary) *ExpressionRefSummary { + sortStrings := func(s []string) []string { + if len(s) == 0 { + return nil + } + c := append([]string(nil), s...) + sort.Strings(c) + return c + } + + datapoint.AttributeKeys = sortStrings(datapoint.AttributeKeys) + datapoint.FieldKeys = sortStrings(datapoint.FieldKeys) + + span.AttributeKeys = sortStrings(span.AttributeKeys) + span.FieldKeys = sortStrings(span.FieldKeys) + + metric.AttributeKeys = sortStrings(metric.AttributeKeys) + metric.FieldKeys = sortStrings(metric.FieldKeys) + + return &ExpressionRefSummary{ + Datapoint: datapoint, + Span: span, + Metric: metric, + } +}