Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion core/schemas/span_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,15 @@ func (f *PluginSpanFilter) Validate() error {
}
}

// SanitizePluginSpanName normalizes a plugin's name into the form embedded in its
// span names.
func SanitizePluginSpanName(name string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-"))
}

// 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
// posthook, prerequesthook, 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.
Expand Down
36 changes: 36 additions & 0 deletions core/schemas/span_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,42 @@ func TestPluginNameFromSpan(t *testing.T) {
}
}

func TestSanitizePluginSpanName(t *testing.T) {
tests := []struct {
in string
want string
}{
{"logging", "logging"},
{"enterprise-prompts", "enterprise-prompts"},
{"Model Catalog Resolver", "model-catalog-resolver"},
{"UPPER", "upper"},
{"", ""},
}
for _, tt := range tests {
if got := SanitizePluginSpanName(tt.in); got != tt.want {
t.Errorf("SanitizePluginSpanName(%q) = %q, want %q", tt.in, got, tt.want)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// TestSanitizedNameMatchesSpanExtraction locks the invariant that the name used to build a
// plugin span (SanitizePluginSpanName(GetName())) is exactly what PluginNameFromSpan extracts
// back out. If these ever diverge, the UI's filterable-plugin list stops matching real spans
// and span filtering silently no-ops — the bug this contract exists to prevent.
func TestSanitizedNameMatchesSpanExtraction(t *testing.T) {
pluginNames := []string{"logging", "enterprise-prompts", "adaptive-loadbalancer", "Has Spaces", "MixedCase"}
stages := []string{"prehook", "posthook", "prerequesthook", "mcp_prehook", "mcp_connect_posthook"}
for _, raw := range pluginNames {
sanitized := SanitizePluginSpanName(raw)
for _, stage := range stages {
spanName := "plugin." + sanitized + "." + stage
if got := PluginNameFromSpan(pluginSpan("1", "", spanName)); got != sanitized {
t.Errorf("PluginNameFromSpan(%q) = %q, want %q", spanName, got, sanitized)
}
}
}
}

func TestPluginSpanFilter_ShouldExportSpan(t *testing.T) {
llm := &Span{SpanID: "llm", Name: "llm.call", Kind: SpanKindLLMCall}
logging := pluginSpan("p1", "", "plugin.logging.prehook")
Expand Down
4 changes: 2 additions & 2 deletions core/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,9 +557,9 @@ func ValidateExternalURL(urlStr string, allowPrivateNetwork bool) error {
return nil
}

// sanitizeSpanName sanitizes a span name to remove capital letters and spaces to make it a valid span name
// sanitizeSpanName sanitizes a span name to remove capital letters and spaces to make it a valid span name.
func sanitizeSpanName(name string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-"))
return schemas.SanitizePluginSpanName(name)
}

// IsCodemodeTool returns true if the given tool name is a codemode tool.
Expand Down
2 changes: 1 addition & 1 deletion docs/enterprise/datadog-connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ By default every plugin's pre- and post-hook execution generates a span, which c
| `exclude` | Export spans for all plugins **except** those listed |
| `include` | Export spans **only** for the listed plugins |

**Built-in plugin names** (for reference): `telemetry`, `prompts`, `logging`, `governance`, `otel`, `semantic_cache`, `compat`, `maxim`.
**Plugin names:** list each plugin using the exact name shown for it in the **Configure Plugin Tracing** sheet — this is the same name that appears in the span (`plugin.<name>.<stage>`), and it is what the filter matches against. Note that some plugins are registered under a different name than their config key: the enterprise prompts and governance plugins appear as `enterprise-prompts` and `enterprise-governance` (not `prompts`/`governance`). Common names include `telemetry`, `logging`, `otel`, `semantic_cache`, `compat`, `maxim`, `enterprise-prompts`, `enterprise-governance`, `datadog`, `bigquery`, `guardrails`, `adaptive-loadbalancer`, and `model-catalog-resolver`. The exact set depends on which plugins are loaded in your deployment.

When a plugin span is filtered out, its children are automatically re-parented to the nearest exported ancestor so the trace hierarchy stays connected. The filter applies to APM trace spans only; it does not change DogStatsD metrics, which are never derived from plugin spans.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/observability/otel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ By default every plugin's pre- and post-hook execution generates a span, which c
| `exclude` | Export spans for all plugins **except** those listed |
| `include` | Export spans **only** for the listed plugins |

**Built-in plugin names** (for reference): `telemetry`, `prompts`, `logging`, `governance`, `otel`, `semantic_cache`, `compat`, `maxim`.
**Plugin names:** list each plugin using the exact name shown for it in the **Configure Plugin Tracing** sheet — this is the same name that appears in the span (`plugin.<name>.<stage>`), and it is what the filter matches against. The built-in OSS plugins are `telemetry`, `prompts`, `logging`, `governance`, `otel`, `semantic_cache`, `compat`, and `maxim`. In enterprise deployments some plugins are registered under a different name than their config key — for example the prompts and governance plugins appear as `enterprise-prompts` and `enterprise-governance` — so always copy the name from the tracing sheet rather than assuming the config key.

When a plugin span is filtered out, its children are automatically re-parented to the nearest exported ancestor so the trace hierarchy stays connected.

Expand Down
12 changes: 11 additions & 1 deletion transports/bifrost-http/handlers/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

type PluginsLoader interface {
GetPluginStatus(ctx context.Context) map[string]schemas.PluginStatus
GetLoadedPluginNames() []string
ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any, placement *schemas.PluginPlacement, order *int) error
RemovePlugin(ctx context.Context, name string) error
// NormalizePluginConfig converts a raw config map to DB-storage format using
Expand Down Expand Up @@ -95,6 +96,7 @@ func (h *PluginsHandler) expandPluginConfigForAPI(name string, config map[string
func (h *PluginsHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.BifrostHTTPMiddleware) {
r.GET("/api/plugins", lib.ChainMiddlewares(h.getPlugins, middlewares...))
r.GET("/api/plugins/builtins", lib.ChainMiddlewares(h.getBuiltinPlugins, middlewares...))
r.GET("/api/plugins/loaded", lib.ChainMiddlewares(h.getLoadedPlugins, middlewares...))
r.GET("/api/plugins/{name}", lib.ChainMiddlewares(h.getPlugin, middlewares...))
r.POST("/api/plugins", lib.ChainMiddlewares(h.createPlugin, middlewares...))
r.PUT("/api/plugins/{name}", lib.ChainMiddlewares(h.updatePlugin, middlewares...))
Expand Down Expand Up @@ -159,13 +161,21 @@ func (h *PluginsHandler) buildPluginResponseWithStatuses(plugin *configstoreTabl
}
}

// getBuiltinPlugins returns the canonical list of built-in plugin names
// getBuiltinPlugins returns the canonical list of built-in plugin names.
func (h *PluginsHandler) getBuiltinPlugins(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, map[string]any{
"plugins": lib.GetBuiltinPluginNames(),
})
}

// getLoadedPlugins returns the names of all plugins currently loaded at runtime, whose
// spans an observability connector can filter.
func (h *PluginsHandler) getLoadedPlugins(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, map[string]any{
"plugins": h.pluginsLoader.GetLoadedPluginNames(),
})
Comment thread
roroghost17 marked this conversation as resolved.
}
Comment thread
roroghost17 marked this conversation as resolved.

// getPlugins gets all plugins
func (h *PluginsHandler) getPlugins(ctx *fasthttp.RequestCtx) {
if h.configStore == nil {
Expand Down
43 changes: 43 additions & 0 deletions transports/bifrost-http/handlers/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (noopPluginsLoader) RemovePlugin(_ context.Context, _ string) error { retur
func (noopPluginsLoader) GetPluginStatus(_ context.Context) map[string]schemas.PluginStatus {
return nil
}
func (noopPluginsLoader) GetLoadedPluginNames() []string { return nil }
func (noopPluginsLoader) NormalizePluginConfig(_ string, _ map[string]any) (map[string]any, error) {
return nil, nil
}
Expand Down Expand Up @@ -163,3 +164,45 @@ func TestUpdatePlugin_ConfigMerge_NewPlugin(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body())
}
}

// namedPluginsLoader is a noopPluginsLoader that returns a fixed set of loaded
// plugin names, used to assert the getLoadedPlugins response contract.
type namedPluginsLoader struct {
noopPluginsLoader
names []string
}

func (l namedPluginsLoader) GetLoadedPluginNames() []string { return l.names }

// TestGetLoadedPlugins verifies that getLoadedPlugins returns the loader's plugin
// names under the "plugins" JSON key, locking the response shape the UI depends on.
func TestGetLoadedPlugins(t *testing.T) {
want := []string{"logging", "telemetry", "enterprise-governance"}
h := &PluginsHandler{
pluginsLoader: namedPluginsLoader{names: want},
configStore: nil,
}

ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("GET")
h.getLoadedPlugins(ctx)

if ctx.Response.StatusCode() != 200 {
t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body())
}

var response struct {
Plugins []string `json:"plugins"`
}
if err := json.Unmarshal(ctx.Response.Body(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(response.Plugins) != len(want) {
t.Fatalf("expected %d plugins, got %d: %v", len(want), len(response.Plugins), response.Plugins)
}
for i, name := range want {
if response.Plugins[i] != name {
t.Errorf("plugins[%d] = %q, want %q", i, response.Plugins[i], name)
}
}
}
24 changes: 24 additions & 0 deletions transports/bifrost-http/lib/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4415,6 +4415,30 @@ func (c *Config) GetLoadedLLMPlugins() []schemas.LLMPlugin {
return nil
}

// GetLoadedPluginNames returns the sanitized names of every currently loaded plugin,
// matching the names embedded in their trace span names.
func (c *Config) GetLoadedPluginNames() []string {
plugins := c.BasePlugins.Load()
if plugins == nil {
return nil
}
seen := make(map[string]struct{}, len(*plugins))
names := make([]string, 0, len(*plugins))
for _, p := range *plugins {
name := schemas.SanitizePluginSpanName(p.GetName())
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
slices.Sort(names)
return names
}

// pluginChunkInterceptor implements StreamChunkInterceptor by calling plugin hooks
type pluginChunkInterceptor struct {
plugins []schemas.HTTPTransportPlugin
Expand Down
10 changes: 10 additions & 0 deletions transports/bifrost-http/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type ServerCallbacks interface {
ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any, placement *schemas.PluginPlacement, order *int) error
RemovePlugin(ctx context.Context, name string) error
GetPluginStatus(ctx context.Context) map[string]schemas.PluginStatus
GetLoadedPluginNames() []string
NormalizePluginConfig(name string, config map[string]any) (map[string]any, error)
ExpandPluginConfigForAPI(name string, config map[string]any) (map[string]any, error)
// Auth related callbacks
Expand Down Expand Up @@ -1158,6 +1159,15 @@ func (s *BifrostHTTPServer) GetPluginStatus(ctx context.Context) map[string]sche
return s.Config.GetPluginStatus()
}

// GetLoadedPluginNames returns the sanitized names of all currently loaded plugins,
// matching the names embedded in their trace span names.
func (s *BifrostHTTPServer) GetLoadedPluginNames() []string {
if s.Config == nil {
return []string{}
}
return s.Config.GetLoadedPluginNames()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// NormalizePluginConfig implements handlers.PluginsLoader. It looks up the plugin
// by name in the ConfigMarshallers cache and calls MarshalConfigForStorage if found.
// Returns nil, nil when the plugin is not loaded or does not implement ConfigMarshallerPlugin.
Expand Down
55 changes: 14 additions & 41 deletions ui/app/workspace/observability/sheets/pluginTracingSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { TriStateCheckbox } from "@/components/ui/tristateCheckbox";
import { getErrorMessage, useGetBuiltinPluginsQuery, useGetPluginQuery, useGetPluginsQuery, useUpdatePluginMutation } from "@/lib/store";
import { getErrorMessage, useGetLoadedPluginsQuery, useGetPluginQuery, useUpdatePluginMutation } from "@/lib/store";
import { PluginSpanFilter } from "@/lib/types/config";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
Expand Down Expand Up @@ -60,10 +60,10 @@ function PluginRow({ name, checked, onChange }: { name: string; checked: boolean
}

export default function PluginTracingSheet({ open, onClose, pluginName, destination }: PluginTracingSheetProps) {
const { data: builtinPluginNames = [] } = useGetBuiltinPluginsQuery();
const { data: allPluginsData } = useGetPluginsQuery();
const customPluginNames = (allPluginsData ?? []).filter((p) => p.isCustom).map((p) => p.name);
const allPlugins = [...builtinPluginNames, ...customPluginNames];
// All currently loaded plugins (built-in, enterprise, custom, and auto-loaded) that can
// emit spans, named to match the connector's span filter. One flat list — the backend
// already returns the complete set, so there's no built-in/custom split to maintain.
const { data: allPlugins = [], isLoading: isLoadingLoadedPlugins } = useGetLoadedPluginsQuery();
const { data: targetPlugin } = useGetPluginQuery(pluginName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const [updatePlugin, { isLoading }] = useUpdatePluginMutation();
const [toggles, setToggles] = useState<Record<string, boolean>>({});
Expand All @@ -73,12 +73,12 @@ export default function PluginTracingSheet({ open, onClose, pluginName, destinat
if (open && !wasOpenRef.current) {
if (!targetPlugin) return; // wait until persisted config is available
const filter = (targetPlugin.config?.plugin_span_filter as PluginSpanFilter | undefined) ?? null;
if (filter?.mode === "include" && allPlugins.length === 0) return;
if (isLoadingLoadedPlugins || allPlugins.length === 0) return;
Comment thread
roroghost17 marked this conversation as resolved.
setToggles(resolveToggleState(filter, allPlugins));
wasOpenRef.current = true;
}
if (!open) wasOpenRef.current = false;
}, [open, targetPlugin, allPlugins]);
}, [open, targetPlugin, allPlugins, isLoadingLoadedPlugins]);

const setToggle = useCallback((name: string, value: boolean) => {
setToggles((prev) => ({ ...prev, [name]: value }));
Expand Down Expand Up @@ -127,55 +127,28 @@ export default function PluginTracingSheet({ open, onClose, pluginName, destinat
<div className="flex flex-col gap-4">
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Built-in Plugins</p>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Plugins</p>
<TriStateCheckbox
allIds={builtinPluginNames}
selectedIds={builtinPluginNames.filter((n) => toggles[n] ?? true)}
allIds={allPlugins}
selectedIds={allPlugins.filter((n) => toggles[n] ?? true)}
onChange={(next) => {
const nextSet = new Set(next);
setToggles((prev) => {
const updated = { ...prev };
for (const n of builtinPluginNames) updated[n] = nextSet.has(n);
for (const n of allPlugins) updated[n] = nextSet.has(n);
return updated;
});
}}
ariaLabel="Toggle all built-in plugin tracing"
data-testid="plugin-tracing-select-all-builtins"
ariaLabel="Toggle all plugin tracing"
data-testid="plugin-tracing-select-all"
/>
</div>
<div className="flex flex-col gap-1.5">
{builtinPluginNames.map((name) => (
{allPlugins.map((name) => (
<PluginRow key={name} name={name} checked={toggles[name] ?? true} onChange={(v) => setToggle(name, v)} />
))}
</div>
</div>

{customPluginNames.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Custom Plugins</p>
<TriStateCheckbox
allIds={customPluginNames}
selectedIds={customPluginNames.filter((n) => toggles[n] ?? true)}
onChange={(next) => {
const nextSet = new Set(next);
setToggles((prev) => {
const updated = { ...prev };
for (const n of customPluginNames) updated[n] = nextSet.has(n);
return updated;
});
}}
ariaLabel="Toggle all custom plugin tracing"
data-testid="plugin-tracing-select-all-custom"
/>
</div>
<div className="flex flex-col gap-1.5">
{customPluginNames.map((name) => (
<PluginRow key={name} name={name} checked={toggles[name] ?? true} onChange={(v) => setToggle(name, v)} />
))}
</div>
</div>
)}
</div>
</div>

Expand Down
10 changes: 10 additions & 0 deletions ui/lib/store/apis/pluginsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export const pluginsApi = baseApi.injectEndpoints({
transformResponse: (response: { plugins: string[] }) => response.plugins || [],
}),

// Get the names of all currently loaded plugins (sanitized to match the names
// embedded in their trace span names). Used by the plugin tracing sheet so it
// lists every plugin that actually emits spans, including enterprise plugins.
getLoadedPlugins: builder.query<string[], void>({
query: () => "/plugins/loaded",
providesTags: ["Plugins"],
transformResponse: (response: { plugins: string[] }) => response.plugins || [],
}),

// Get all plugins
getPlugins: builder.query<Plugin[], void>({
query: () => "/plugins",
Expand Down Expand Up @@ -97,6 +106,7 @@ export const pluginsApi = baseApi.injectEndpoints({

export const {
useGetBuiltinPluginsQuery,
useGetLoadedPluginsQuery,
useGetPluginsQuery,
useGetPluginQuery,
useCreatePluginMutation,
Expand Down
Loading