-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: adds plugin span filtering for multiple connectors #4199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Pratham-Mishra04
merged 1 commit into
dev
from
06-09-feat_adds_plugin_span_filtering_for_multiple_connectors
Jun 9, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| package schemas | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "slices" | ||
| "strings" | ||
| ) | ||
|
|
||
| // PluginSpanFilterMode controls whether the plugins list is an allowlist or denylist. | ||
| type PluginSpanFilterMode string | ||
|
|
||
| const ( | ||
| // PluginSpanFilterModeInclude exports only the listed plugins' spans. | ||
| PluginSpanFilterModeInclude PluginSpanFilterMode = "include" | ||
| // PluginSpanFilterModeExclude exports everything except the listed plugins' spans. | ||
| PluginSpanFilterModeExclude PluginSpanFilterMode = "exclude" | ||
| ) | ||
|
|
||
| // PluginSpanFilter configures which plugin spans an observability connector exports. | ||
| // Mode "include" exports only the listed plugins; mode "exclude" exports everything | ||
| // except them. It is shared by every observability connector (OTEL, Datadog, BigQuery) | ||
| // so the span-name contract and reparenting behavior stay consistent across exporters. | ||
| type PluginSpanFilter struct { | ||
| Mode PluginSpanFilterMode `json:"mode"` | ||
| Plugins []string `json:"plugins"` | ||
| } | ||
|
|
||
| // Validate reports whether the filter's mode is one of the two valid modes. | ||
| // A nil filter is valid (it filters nothing). | ||
| func (f *PluginSpanFilter) Validate() error { | ||
| if f == nil { | ||
| return nil | ||
| } | ||
| switch f.Mode { | ||
| case PluginSpanFilterModeInclude, PluginSpanFilterModeExclude: | ||
| return nil | ||
| default: | ||
| return fmt.Errorf("plugin_span_filter.mode %q is invalid: must be %q or %q", | ||
| f.Mode, PluginSpanFilterModeInclude, PluginSpanFilterModeExclude) | ||
| } | ||
| } | ||
|
|
||
| // PluginNameFromSpan extracts "<name>" from a plugin span whose name follows the | ||
| // core tracer contract "plugin.<name>.<stage>", where <stage> is one of prehook, | ||
| // posthook, mcp_prehook, mcp_posthook, mcp_connect_prehook, or mcp_connect_posthook | ||
| // (see core/bifrost.go). It returns "" for non-plugin spans or names that don't match | ||
| // the contract (wrong prefix, or fewer than three segments), so malformed names pass | ||
| // through ShouldExportSpan as exported rather than being silently filtered. | ||
| // | ||
| // The <stage> segment is intentionally not constrained to a fixed list: the tracer | ||
| // emits several hook stages (including the mcp_* variants above), so pinning it to | ||
| // just prehook/posthook would make every MCP-hook span unfilterable. | ||
| func PluginNameFromSpan(span *Span) string { | ||
| if span == nil || span.Kind != SpanKindPlugin { | ||
| return "" | ||
| } | ||
| parts := strings.SplitN(span.Name, ".", 3) | ||
| if len(parts) != 3 || parts[0] != "plugin" || parts[1] == "" { | ||
| return "" | ||
| } | ||
| return parts[1] | ||
| } | ||
|
|
||
| // ShouldExportSpan reports whether a span survives the filter. Non-plugin spans and | ||
| // spans evaluated against a nil filter are always exported. Plugin spans are checked | ||
| // against the filter's plugin list and mode. | ||
| func (f *PluginSpanFilter) ShouldExportSpan(span *Span) bool { | ||
| if f == nil || span == nil || span.Kind != SpanKindPlugin { | ||
| return true | ||
| } | ||
| pluginName := PluginNameFromSpan(span) | ||
| if pluginName == "" { | ||
| // Malformed plugin span name: export rather than silently drop. | ||
| return true | ||
| } | ||
| inList := slices.Contains(f.Plugins, pluginName) | ||
| if f.Mode == PluginSpanFilterModeInclude { | ||
| return inList | ||
| } | ||
| return !inList // exclude mode | ||
| } | ||
|
|
||
| // BuildReparentMap returns a map of filteredSpanID → effective ancestor spanID for all | ||
| // spans that the filter removes. When plugin spans are chained (each span's parent is the | ||
| // previous plugin's span), removing a span from the middle would leave its children with a | ||
| // dangling parent ID. The map lets callers rewrite those parent IDs to the nearest exported | ||
| // ancestor, handling consecutive filtered spans in a chain. Returns nil when the filter is | ||
| // nil or nothing is filtered. | ||
| func (f *PluginSpanFilter) BuildReparentMap(spans []*Span) map[string]string { | ||
| if f == nil { | ||
| return nil | ||
| } | ||
| // First pass: record direct parent ID for every filtered span. | ||
| filtered := make(map[string]string) // spanID -> parentID | ||
| for _, span := range spans { | ||
| if !f.ShouldExportSpan(span) { | ||
| filtered[span.SpanID] = span.ParentID | ||
| } | ||
| } | ||
| if len(filtered) == 0 { | ||
| return nil | ||
| } | ||
| // Second pass: resolve chains so each filtered span maps to its first exported ancestor. | ||
| // Cap the walk at len(filtered) to break out of any cycle caused by malformed span data. | ||
| maxHops := len(filtered) | ||
| for spanID := range filtered { | ||
| parentID := filtered[spanID] | ||
| for range maxHops { | ||
| grandParentID, isFiltered := filtered[parentID] | ||
| if !isFiltered { | ||
| break | ||
| } | ||
| parentID = grandParentID | ||
| } | ||
| filtered[spanID] = parentID | ||
| } | ||
|
roroghost17 marked this conversation as resolved.
|
||
| return filtered | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package schemas | ||
|
|
||
| import "testing" | ||
|
|
||
| func pluginSpan(id, parent, name string) *Span { | ||
| return &Span{SpanID: id, ParentID: parent, Name: name, Kind: SpanKindPlugin} | ||
| } | ||
|
|
||
| func TestPluginSpanFilter_Validate(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| filter *PluginSpanFilter | ||
| wantErr bool | ||
| }{ | ||
| {"nil filter", nil, false}, | ||
| {"include", &PluginSpanFilter{Mode: PluginSpanFilterModeInclude}, false}, | ||
| {"exclude", &PluginSpanFilter{Mode: PluginSpanFilterModeExclude}, false}, | ||
| {"invalid mode", &PluginSpanFilter{Mode: "nonsense"}, true}, | ||
| {"empty mode", &PluginSpanFilter{Mode: ""}, true}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if err := tt.filter.Validate(); (err != nil) != tt.wantErr { | ||
| t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPluginNameFromSpan(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| span *Span | ||
| want string | ||
| }{ | ||
| {"prehook", pluginSpan("1", "", "plugin.logging.prehook"), "logging"}, | ||
| {"posthook", pluginSpan("1", "", "plugin.compat.posthook"), "compat"}, | ||
| {"mcp hook stage still resolves", pluginSpan("1", "", "plugin.governance.mcp_connect_prehook"), "governance"}, | ||
| {"non-plugin kind", &Span{Name: "plugin.logging.prehook", Kind: SpanKindLLMCall}, ""}, | ||
| {"malformed name", pluginSpan("1", "", "plugin"), ""}, | ||
| {"missing stage", pluginSpan("1", "", "plugin.logging"), ""}, | ||
| {"wrong prefix", pluginSpan("1", "", "otel.logging.prehook"), ""}, | ||
| {"empty name segment", pluginSpan("1", "", "plugin..prehook"), ""}, | ||
| {"nil span", nil, ""}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if got := PluginNameFromSpan(tt.span); got != tt.want { | ||
| t.Errorf("PluginNameFromSpan() = %q, want %q", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| func TestPluginSpanFilter_ShouldExportSpan(t *testing.T) { | ||
| llm := &Span{SpanID: "llm", Name: "llm.call", Kind: SpanKindLLMCall} | ||
| logging := pluginSpan("p1", "", "plugin.logging.prehook") | ||
| compat := pluginSpan("p2", "", "plugin.compat.prehook") | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| filter *PluginSpanFilter | ||
| span *Span | ||
| want bool | ||
| }{ | ||
| {"nil filter exports plugin", nil, logging, true}, | ||
| {"non-plugin always exported", &PluginSpanFilter{Mode: PluginSpanFilterModeInclude, Plugins: []string{"logging"}}, llm, true}, | ||
| {"include lists plugin", &PluginSpanFilter{Mode: PluginSpanFilterModeInclude, Plugins: []string{"logging"}}, logging, true}, | ||
| {"include omits plugin", &PluginSpanFilter{Mode: PluginSpanFilterModeInclude, Plugins: []string{"logging"}}, compat, false}, | ||
| {"exclude lists plugin", &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"logging"}}, logging, false}, | ||
| {"exclude omits plugin", &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"logging"}}, compat, true}, | ||
| {"malformed plugin span exported", &PluginSpanFilter{Mode: PluginSpanFilterModeInclude, Plugins: []string{"logging"}}, pluginSpan("p3", "", "plugin"), true}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if got := tt.filter.ShouldExportSpan(tt.span); got != tt.want { | ||
| t.Errorf("ShouldExportSpan() = %v, want %v", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPluginSpanFilter_BuildReparentMap(t *testing.T) { | ||
| t.Run("nil filter returns nil", func(t *testing.T) { | ||
| f := (*PluginSpanFilter)(nil) | ||
| if got := f.BuildReparentMap([]*Span{pluginSpan("1", "", "plugin.logging.prehook")}); got != nil { | ||
| t.Errorf("expected nil, got %v", got) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("nothing filtered returns nil", func(t *testing.T) { | ||
| f := &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"absent"}} | ||
| spans := []*Span{pluginSpan("1", "", "plugin.logging.prehook")} | ||
| if got := f.BuildReparentMap(spans); got != nil { | ||
| t.Errorf("expected nil, got %v", got) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("single filtered span maps to its parent", func(t *testing.T) { | ||
| // root(llm) <- logging <- compat. Exclude logging only. | ||
| f := &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"logging"}} | ||
| spans := []*Span{ | ||
| {SpanID: "root", Name: "llm.call", Kind: SpanKindLLMCall}, | ||
| pluginSpan("logging", "root", "plugin.logging.prehook"), | ||
| pluginSpan("compat", "logging", "plugin.compat.prehook"), | ||
| } | ||
| got := f.BuildReparentMap(spans) | ||
| if got["logging"] != "root" { | ||
| t.Errorf("logging should reparent to root, got %q", got["logging"]) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("chain of filtered spans resolves to first exported ancestor", func(t *testing.T) { | ||
| // root(llm) <- a <- b <- c. Exclude a and b. c should reparent to root. | ||
| f := &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"a", "b"}} | ||
| spans := []*Span{ | ||
| {SpanID: "root", Name: "llm.call", Kind: SpanKindLLMCall}, | ||
| pluginSpan("a", "root", "plugin.a.prehook"), | ||
| pluginSpan("b", "a", "plugin.b.prehook"), | ||
| pluginSpan("c", "b", "plugin.c.prehook"), | ||
| } | ||
| got := f.BuildReparentMap(spans) | ||
| if got["a"] != "root" { | ||
| t.Errorf("a should resolve to root, got %q", got["a"]) | ||
| } | ||
| if got["b"] != "root" { | ||
| t.Errorf("b should resolve to root, got %q", got["b"]) | ||
| } | ||
| if _, ok := got["c"]; ok { | ||
| t.Errorf("c is exported and should not be in the map") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("cycle is bounded and does not hang", func(t *testing.T) { | ||
| // Malformed: a's parent is b, b's parent is a. Both filtered. | ||
| f := &PluginSpanFilter{Mode: PluginSpanFilterModeExclude, Plugins: []string{"a", "b"}} | ||
| spans := []*Span{ | ||
| pluginSpan("a", "b", "plugin.a.prehook"), | ||
| pluginSpan("b", "a", "plugin.b.prehook"), | ||
| } | ||
| _ = f.BuildReparentMap(spans) // must terminate | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.