diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 24f81f72fef..f3151e06534 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -203,29 +203,33 @@ const ( MCPContextKeyIncludeClients BifrostContextKey = "mcp-include-clients" // Context key for whitelist client filtering MCPContextKeyIncludeTools BifrostContextKey = "mcp-include-tools" // Context key for whitelist tool filtering (Note: toolName should be in "clientName-toolName" format for individual tools, or "clientName-*" for wildcard) - BifrostContextKeySelectedKeyID BifrostContextKey = "bifrost-selected-key-id" // string (to store the selected key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedKeyName BifrostContextKey = "bifrost-selected-key-name" // string (to store the selected key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceVirtualKeyID BifrostContextKey = "bifrost-governance-virtual-key-id" // string (to store the virtual key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceVirtualKeyName BifrostContextKey = "bifrost-governance-virtual-key-name" // string (to store the virtual key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceTeamID BifrostContextKey = "bifrost-governance-team-id" // string (to store the team ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceRoutingRuleName BifrostContextKey = "bifrost-governance-routing-rule-name" // string (to store the routing rule name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptName BifrostContextKey = "bifrost-selected-prompt-name" // string (display name of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptVersion BifrostContextKey = "bifrost-selected-prompt-version" // string (numeric version as string, e.g. "3" (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeySelectedPromptID BifrostContextKey = "bifrost-selected-prompt-id" // string (id of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceIncludeOnlyKeys BifrostContextKey = "bf-governance-include-only-keys" // []string (to store the include-only key IDs for provider config routing (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyNumberOfRetries BifrostContextKey = "bifrost-number-of-retries" // int (to store the number of retries (set by bifrost - DO NOT SET THIS MANUALLY)) - BifrostContextKeyFallbackIndex BifrostContextKey = "bifrost-fallback-index" // int (to store the fallback index (set by bifrost - DO NOT SET THIS MANUALLY)) 0 for primary, 1 for first fallback, etc. - BifrostContextKeyStreamEndIndicator BifrostContextKey = "bifrost-stream-end-indicator" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - BifrostContextKeyStreamIdleTimeout BifrostContextKey = "bifrost-stream-idle-timeout" // time.Duration (per-chunk idle timeout for streaming) - BifrostContextKeySkipKeySelection BifrostContextKey = "bifrost-skip-key-selection" // bool (will pass an empty key to the provider) - BifrostContextKeyExtraHeaders BifrostContextKey = "bifrost-extra-headers" // map[string][]string - BifrostContextKeyURLPath BifrostContextKey = "bifrost-extra-url-path" // string + BifrostContextKeySelectedKeyID BifrostContextKey = "bifrost-selected-key-id" // string (to store the selected key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedKeyName BifrostContextKey = "bifrost-selected-key-name" // string (to store the selected key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceVirtualKeyID BifrostContextKey = "bifrost-governance-virtual-key-id" // string (to store the virtual key ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceVirtualKeyName BifrostContextKey = "bifrost-governance-virtual-key-name" // string (to store the virtual key name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamID BifrostContextKey = "bifrost-governance-team-id" // string (to store the team ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceTeamIDs BifrostContextKey = "bifrost-governance-team-ids" // []string (all teams a user/AP request belongs to; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceTeamNames BifrostContextKey = "bifrost-governance-team-names" // []string (display names, aligned with team-ids; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceBusinessUnitIDs BifrostContextKey = "bifrost-governance-business-unit-ids" // []string (distinct BUs across the user's teams; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceBusinessUnitNames BifrostContextKey = "bifrost-governance-business-unit-names" // []string (display names, aligned with business-unit-ids; set by enterprise governance plugin - DO NOT SET THIS MANUALLY) + BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceRoutingRuleName BifrostContextKey = "bifrost-governance-routing-rule-name" // string (to store the routing rule name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptName BifrostContextKey = "bifrost-selected-prompt-name" // string (display name of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptVersion BifrostContextKey = "bifrost-selected-prompt-version" // string (numeric version as string, e.g. "3" (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeySelectedPromptID BifrostContextKey = "bifrost-selected-prompt-id" // string (id of the selected prompt (set by prompts plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyGovernanceIncludeOnlyKeys BifrostContextKey = "bf-governance-include-only-keys" // []string (to store the include-only key IDs for provider config routing (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) + BifrostContextKeyNumberOfRetries BifrostContextKey = "bifrost-number-of-retries" // int (to store the number of retries (set by bifrost - DO NOT SET THIS MANUALLY)) + BifrostContextKeyFallbackIndex BifrostContextKey = "bifrost-fallback-index" // int (to store the fallback index (set by bifrost - DO NOT SET THIS MANUALLY)) 0 for primary, 1 for first fallback, etc. + BifrostContextKeyStreamEndIndicator BifrostContextKey = "bifrost-stream-end-indicator" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) + BifrostContextKeyStreamIdleTimeout BifrostContextKey = "bifrost-stream-idle-timeout" // time.Duration (per-chunk idle timeout for streaming) + BifrostContextKeySkipKeySelection BifrostContextKey = "bifrost-skip-key-selection" // bool (will pass an empty key to the provider) + BifrostContextKeyExtraHeaders BifrostContextKey = "bifrost-extra-headers" // map[string][]string + BifrostContextKeyURLPath BifrostContextKey = "bifrost-extra-url-path" // string BifrostContextKeyUseRawRequestBody BifrostContextKey = "bifrost-use-raw-request-body" BifrostContextKeyChangeRequestType BifrostContextKey = "bifrost-change-request-type" // RequestType (set by plugins to trigger request type conversion in core, e.g. text->chat or chat->responses) BifrostContextKeySendBackRawRequest BifrostContextKey = "bifrost-send-back-raw-request" // bool (per-request override — read by bifrost.go, never overwritten) @@ -1433,7 +1437,7 @@ type BifrostResponseExtraFields struct { ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` // params dropped by the compat plugin based on model catalog ProviderResponseHeaders map[string]string `json:"provider_response_headers,omitempty"` // HTTP response headers from the provider (filtered to exclude transport-level headers) - PassthroughPath string `json:"passthrough_path,omitempty"` // Stripped provider path for passthrough requests, e.g. "/v1/chat/completions" + PassthroughPath string `json:"passthrough_path,omitempty"` // Stripped provider path for passthrough requests, e.g. "/v1/chat/completions" } type BifrostMCPResponseExtraFields struct { @@ -1670,15 +1674,15 @@ func (e *ErrorField) UnmarshalJSON(data []byte) error { // BifrostErrorExtraFields contains additional fields in an error response. type BifrostErrorExtraFields struct { - Provider ModelProvider `json:"provider,omitempty"` - OriginalModelRequested string `json:"original_model_requested,omitempty"` - ResolvedModelUsed string `json:"resolved_model_used,omitempty"` - RequestType RequestType `json:"request_type,omitempty"` - MCPRequestType MCPRequestType `json:"mcp_request_type,omitempty"` - RawRequest interface{} `json:"raw_request,omitempty"` - RawResponse interface{} `json:"raw_response,omitempty"` - ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` - DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` - KeyStatuses []KeyStatus `json:"key_statuses,omitempty"` - MCPAuthRequired *MCPAuthRequiredError `json:"mcp_auth_required,omitempty"` // Set when a per-user MCP tool requires the caller to complete an inline auth flow (OAuth or headers) + Provider ModelProvider `json:"provider,omitempty"` + OriginalModelRequested string `json:"original_model_requested,omitempty"` + ResolvedModelUsed string `json:"resolved_model_used,omitempty"` + RequestType RequestType `json:"request_type,omitempty"` + MCPRequestType MCPRequestType `json:"mcp_request_type,omitempty"` + RawRequest interface{} `json:"raw_request,omitempty"` + RawResponse interface{} `json:"raw_response,omitempty"` + ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` + DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` + KeyStatuses []KeyStatus `json:"key_statuses,omitempty"` + MCPAuthRequired *MCPAuthRequiredError `json:"mcp_auth_required,omitempty"` // Set when a per-user MCP tool requires the caller to complete an inline auth flow (OAuth or headers) } diff --git a/framework/logstore/matviews.go b/framework/logstore/matviews.go index d341c209ed6..44b11aa84fb 100644 --- a/framework/logstore/matviews.go +++ b/framework/logstore/matviews.go @@ -116,12 +116,38 @@ const filterDataMatViewWindow = "30 days" // - requiredColumns: resolved column aliases used by repairMatViewShapes // to detect drifted matviews. Declared explicitly (not parsed from // selectExpr) so SQL fragments like COALESCE(...) cannot poison the check. +// - bodyOverride: when set, replaces the whole `SELECT DISTINCT ... FROM +// logs WHERE ...` body (selectExpr/whereExpr are ignored). Used by the +// multi-valued team / business-unit views, which must union the scalar +// column with the JSON-array column rather than read a single column. type filterMatViewDef struct { name string selectExpr string whereExpr string uniqueIdx string requiredColumns []string + bodyOverride string +} + +// multiValueFilterMatViewBody builds the SELECT body for a filter matview whose +// dimension is single-valued on the scalar column (old / pre-migration rows and +// the VK-team path) and multi-valued on the JSON-array column (the enterprise +// user/AP path). It reuses teamOrBUFanoutFrom — the same fan-out the ranking / +// histogram readers use — so a team / business unit that only ever appears in +// the JSON array still surfaces in the filter dropdown. The fanned-out +// dim_id/dim_name become the dropdown id/name; the visibility columns +// (user_id, team_id, virtual_key_id) come from the original log row (exposed via +// l.* by the fan-out subquery) so DAC scope still applies. idCol is the scalar +// id column ("team_id" / "business_unit_id"). +func multiValueFilterMatViewBody(idCol string) string { + from, _ := teamOrBUFanoutFrom(idCol) + return fmt.Sprintf( + "SELECT DISTINCT dim_id AS id, dim_name AS name, "+ + "COALESCE(user_id, '') AS user_id, COALESCE(team_id, '') AS team_id, "+ + "COALESCE(virtual_key_id, '') AS virtual_key_id "+ + "FROM %s WHERE timestamp >= NOW() - INTERVAL '%s' AND dim_id != '' AND dim_name != ''", + from, filterDataMatViewWindow, + ) } // scopeProjection is the per-row visibility columns appended to every @@ -202,12 +228,12 @@ var filterMatViews = []filterMatViewDef{ }, { name: "mv_filter_teams", - // team_id is exposed as "id" for the dropdown and also as the scope - // column for uniform DAC predicates. - selectExpr: "team_id AS id, team_name AS name, " + - "COALESCE(user_id, '') AS user_id, COALESCE(team_id, '') AS team_id, " + - "COALESCE(virtual_key_id, '') AS virtual_key_id", - whereExpr: "team_id IS NOT NULL AND team_id != '' AND team_name IS NOT NULL AND team_name != ''", + // A request can belong to one team (scalar team_id) or many (JSON-array + // team_ids, enterprise user/AP path). bodyOverride unions both so every + // team shows in the dropdown, not just the scalar primary. team_id is + // exposed as the dropdown "id" and the original row's team_id is kept as + // the scope column for uniform DAC predicates. + bodyOverride: multiValueFilterMatViewBody("team_id"), uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, @@ -219,16 +245,17 @@ var filterMatViews = []filterMatViewDef{ requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, { - name: "mv_filter_users", - selectExpr: "user_id AS id, user_name AS name, " + scopeProjection, - whereExpr: "user_id IS NOT NULL AND user_id != '' AND user_name IS NOT NULL AND user_name != ''", + name: "mv_filter_users", + selectExpr: "user_id AS id, user_name AS name, " + scopeProjection, + whereExpr: "user_id IS NOT NULL AND user_id != '' AND user_name IS NOT NULL AND user_name != ''", uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, { - name: "mv_filter_business_units", - selectExpr: "business_unit_id AS id, business_unit_name AS name, " + scopeProjection, - whereExpr: "business_unit_id IS NOT NULL AND business_unit_id != '' AND business_unit_name IS NOT NULL AND business_unit_name != ''", + name: "mv_filter_business_units", + // Same scalar-or-JSON-array union as mv_filter_teams: a request can carry + // one business unit (scalar) or many (JSON-array business_unit_ids). + bodyOverride: multiValueFilterMatViewBody("business_unit_id"), uniqueIdx: "id, name, " + scopeIdxColumns, requiredColumns: append([]string{"id", "name"}, scopeRequiredColumns...), }, @@ -242,11 +269,14 @@ var filterMatViewKeyPairColumns = map[[2]string]string{ {"routing_rule_id", "routing_rule_name"}: "mv_filter_routing_rules", {"team_id", "team_name"}: "mv_filter_teams", {"customer_id", "customer_name"}: "mv_filter_customers", - {"user_id", "user_name"}: "mv_filter_users", + {"user_id", "user_name"}: "mv_filter_users", {"business_unit_id", "business_unit_name"}: "mv_filter_business_units", } func filterMatViewDDL(v filterMatViewDef) string { + if v.bodyOverride != "" { + return fmt.Sprintf("CREATE MATERIALIZED VIEW IF NOT EXISTS %s AS %s", v.name, v.bodyOverride) + } return fmt.Sprintf( "CREATE MATERIALIZED VIEW IF NOT EXISTS %s AS SELECT DISTINCT %s FROM logs WHERE timestamp >= NOW() - INTERVAL '%s' AND (%s)", v.name, v.selectExpr, filterDataMatViewWindow, v.whereExpr, @@ -736,7 +766,9 @@ func canUseMatViewFilters(f SearchFilters) bool { f.MinTokens == nil && f.MaxTokens == nil && f.MinCost == nil && f.MaxCost == nil && !f.MissingCostOnly && - len(f.CacheHitTypes) == 0 + len(f.CacheHitTypes) == 0 && + len(f.TeamIDs) == 0 && + len(f.BusinessUnitIDs) == 0 } // canUseMatView checks both that materialized views are ready (created and @@ -1738,10 +1770,10 @@ func (s *RDBLogStore) getDimensionRankingsFromMatView(ctx context.Context, filte } type row struct { - ID string `gorm:"column:id"` - Total int64 `gorm:"column:total"` - TotalTkns int64 `gorm:"column:total_tkns"` - TotalCost float64 `gorm:"column:total_cost"` + ID string `gorm:"column:id"` + Total int64 `gorm:"column:total"` + TotalTkns int64 `gorm:"column:total_tkns"` + TotalCost float64 `gorm:"column:total_cost"` } var results []row diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go index a6fffe5e682..905fedcaaeb 100644 --- a/framework/logstore/migrations.go +++ b/framework/logstore/migrations.go @@ -336,6 +336,15 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationRecreateFilterUsersMatView(ctx, db); err != nil { return err } + if err := migrationAddMultiTeamBusinessUnitColumns(ctx, db); err != nil { + return err + } + if err := migrationAddMultiTeamBusinessUnitGINIndexes(ctx, db); err != nil { + return err + } + if err := migrationRecreateFilterTeamBUMatViews(ctx, db); err != nil { + return err + } // migrationSplitFilterDataMatView is intentionally NOT invoked in this // release. Dropping mv_logs_filterdata while old replicas are still // serving /api/logs/filterdata from it would surface "relation does not @@ -2005,6 +2014,38 @@ func migrationAddMetadataGINIndex(ctx context.Context, db *gorm.DB) error { return nil } +// migrationAddMultiTeamBusinessUnitGINIndexes registers the GIN indexes backing +// multi-team / multi-BU log filtering. Like migrationAddMetadataGINIndex, the +// build itself is deferred to ensureMultiTeamBusinessUnitGINIndexes (post-startup, +// background, CONCURRENTLY); this migration exists only to provide a rollback that +// drops the indexes. Postgres-only. +func migrationAddMultiTeamBusinessUnitGINIndexes(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = false + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_multi_team_bu_gin_indexes_v1", + Migrate: func(tx *gorm.DB) error { + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if tx.Dialector.Name() == "postgres" { + if err := tx.Exec("DROP INDEX IF EXISTS idx_logs_team_ids_gin").Error; err != nil { + return fmt.Errorf("failed to drop team_ids GIN index: %w", err) + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_logs_business_unit_ids_gin").Error; err != nil { + return fmt.Errorf("failed to drop business_unit_ids GIN index: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while registering multi team/BU GIN indexes: %s", err.Error()) + } + return nil +} + // ensureMetadataGINIndex checks whether idx_logs_metadata_gin exists and is valid. // If the index is missing or was left in an INVALID state by a previously interrupted // CREATE INDEX CONCURRENTLY, it drops the remnant and rebuilds the index synchronously. @@ -2088,6 +2129,59 @@ func cleanupInvalidLogMetadata(ctx context.Context, conn *sql.Conn) error { `) } +// ensureArrayGINIndex builds a partial jsonb_path_ops GIN index on a JSON-array +// text column (e.g. team_ids) so `column::jsonb @> '[...]'` containment filters +// are indexed. Mirrors ensureMetadataGINIndex's lifecycle: tolerates an INVALID +// remnant from an interrupted build and builds CONCURRENTLY so writers are not +// blocked. No data cleanup is needed — the log writer only ever stores a valid +// JSON array or NULL in these columns. indexName/column are internal constants +// (not user input), so identifier interpolation is safe. Postgres-only. +func ensureArrayGINIndex(ctx context.Context, conn *sql.Conn, indexName, column string) error { + var indexValid bool + if err := conn.QueryRowContext(ctx, ` + SELECT COALESCE(bool_and(pi.indisvalid), false) + FROM pg_class pc + JOIN pg_index pi ON pi.indrelid = pc.oid + JOIN pg_class ic ON ic.oid = pi.indexrelid + WHERE pc.relname = 'logs' AND ic.relname = $1 + `, indexName).Scan(&indexValid); err != nil { + return fmt.Errorf("failed to query GIN index validity for %s: %w", indexName, err) + } + if indexValid { + return nil + } + + // Drop any INVALID remnant left by a prior interrupted CONCURRENTLY build. + if _, err := conn.ExecContext(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+indexName); err != nil { + return fmt.Errorf("failed to drop invalid GIN index %s: %w", indexName, err) + } + + // Non-fatal tuning to speed up the build. + _, _ = conn.ExecContext(ctx, "SET maintenance_work_mem = '512MB'") + _, _ = conn.ExecContext(ctx, "SET max_parallel_maintenance_workers = 4") + + // jsonb_path_ops supports @> (containment) and is ~3x smaller than the default + // opclass. The partial predicate matches the IS JSON ARRAY guard the filter + // query adds (rdb.go), so the planner uses this index. + stmt := fmt.Sprintf( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON logs USING gin ((%s::jsonb) jsonb_path_ops) WHERE %s IS NOT NULL AND %s IS JSON ARRAY", + indexName, column, column, column, + ) + if _, err := conn.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to create GIN index %s: %w", indexName, err) + } + return nil +} + +// ensureMultiTeamBusinessUnitGINIndexes builds the GIN indexes backing multi-team +// and multi-BU log filtering (team_ids / business_unit_ids). +func ensureMultiTeamBusinessUnitGINIndexes(ctx context.Context, conn *sql.Conn) error { + if err := ensureArrayGINIndex(ctx, conn, "idx_logs_team_ids_gin", "team_ids"); err != nil { + return err + } + return ensureArrayGINIndex(ctx, conn, "idx_logs_business_unit_ids_gin", "business_unit_ids") +} + // migrationAddDashboardEnhancements adds cached_read_tokens column to logs table. // The expensive backfill, covering index rebuild, and MCP index creation are deferred // to ensureDashboardEnhancements (called post-startup in a background goroutine) so @@ -2837,6 +2931,50 @@ func migrationAddGovernanceContextColumns(ctx context.Context, db *gorm.DB) erro return nil } +// migrationAddMultiTeamBusinessUnitColumns adds the JSON-array columns capturing +// the full deduped set of teams / business units a request belongs to (enterprise +// user/AP path). The scalar team_id/business_unit_id remain the primary; these +// power display, multi-team filtering (jsonb @> + GIN), and fan-out aggregation. +func migrationAddMultiTeamBusinessUnitColumns(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + + columns := []string{"team_ids", "team_names", "business_unit_ids", "business_unit_names"} + + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_multi_team_business_unit_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if !mig.HasColumn(&Log{}, col) { + if err := mig.AddColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + for _, col := range columns { + if mig.HasColumn(&Log{}, col) { + if err := mig.DropColumn(&Log{}, col); err != nil { + return err + } + } + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while adding multi team/business-unit columns: %s", err.Error()) + } + return nil +} + // migrationRecreateMatViewsWithGovernanceColumns drops and recreates materialized views // so they include the new governance context columns (user_id, team_id, customer_id, business_unit_id). // The actual rebuild is deferred to ensureMatViews, which runs after startup on @@ -3333,3 +3471,38 @@ func migrationRecreateFilterUsersMatView(ctx context.Context, db *gorm.DB) error } return nil } + +// migrationRecreateFilterTeamBUMatViews drops mv_filter_teams and +// mv_filter_business_units so ensureMatViews recreates them with the multi-value +// body (scalar column UNION the JSON-array column). Required because +// repairMatViewShapes only detects drift by column presence, and the column +// shape (id, name, user_id, team_id, virtual_key_id) is unchanged — only the +// SELECT body changed — so the views would otherwise keep their old scalar-only +// definition. Recreated views keep identical columns, so old replicas reading +// them during a rolling deploy are unaffected (no legacyMatViewNames dance). +func migrationRecreateFilterTeamBUMatViews(ctx context.Context, db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_recreate_filter_team_bu_matviews_multivalue", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + for _, view := range []string{"mv_filter_teams", "mv_filter_business_units"} { + if err := tx.Exec("DROP MATERIALIZED VIEW IF EXISTS " + view + " CASCADE").Error; err != nil { + return fmt.Errorf("failed to drop %s: %w", view, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while recreating filter team/business-unit matviews: %s", err.Error()) + } + return nil +} diff --git a/framework/logstore/multi_team_filter_test.go b/framework/logstore/multi_team_filter_test.go new file mode 100644 index 00000000000..8a43582cd15 --- /dev/null +++ b/framework/logstore/multi_team_filter_test.go @@ -0,0 +1,82 @@ +package logstore + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMultiValueDimensionFilterSQL verifies the predicate that matches logs by a +// dimension that is scalar on the primary column and multi-valued on the JSON +// array column: it must OR the scalar IN with guarded array containment per id, +// and bind args in order (the id slice first, then one JSON fragment per id). +func TestMultiValueDimensionFilterSQL(t *testing.T) { + sql, args := multiValueDimensionFilterSQL("team_id", "team_ids", []string{"t1", "t2"}) + + // Outer parens so it ANDs as one group with other filters. + assert.True(t, strings.HasPrefix(sql, "(") && strings.HasSuffix(sql, ")"), "must be parenthesised: %s", sql) + // Scalar primary path (covers VK-team / pre-migration / NULL-array rows). + assert.Contains(t, sql, "team_id IN ?") + // Partial-index guard so the planner uses the jsonb_path_ops GIN. + assert.Contains(t, sql, "team_ids IS NOT NULL AND team_ids IS JSON ARRAY") + // One containment test per requested id. + assert.Equal(t, 2, strings.Count(sql, "team_ids::jsonb @> ?::jsonb")) + + // Args: the id slice first (for `IN ?`), then a JSON array fragment per id. + require.Len(t, args, 3) + assert.Equal(t, []string{"t1", "t2"}, args[0]) + assert.Equal(t, `["t1"]`, args[1]) + assert.Equal(t, `["t2"]`, args[2]) + + // Works for the BU columns too. + buSQL, buArgs := multiValueDimensionFilterSQL("business_unit_id", "business_unit_ids", []string{"bu1"}) + assert.Contains(t, buSQL, "business_unit_id IN ?") + assert.Contains(t, buSQL, "business_unit_ids::jsonb @> ?::jsonb") + require.Len(t, buArgs, 2) + assert.Equal(t, `["bu1"]`, buArgs[1]) +} + +// TestTeamOrBUFanoutFrom verifies the fan-out FROM subquery: it must unnest the +// array columns (id+name aligned by ordinality) for array rows and fall back to +// the scalar id/name for non-array rows, with mutually exclusive branches, and +// be aliased AS logs so DAC scope + filters still resolve. +func TestTeamOrBUFanoutFrom(t *testing.T) { + teamSQL, ok := teamOrBUFanoutFrom("team_id") + require.True(t, ok) + assert.Contains(t, teamSQL, "jsonb_array_elements_text(l.team_ids::jsonb) WITH ORDINALITY") + assert.Contains(t, teamSQL, "jsonb_array_elements_text(l.team_names::jsonb) WITH ORDINALITY") + assert.Contains(t, teamSQL, "ON n.ord = t.ord", "names aligned with ids by ordinality") + // array branch guard + mutually-exclusive scalar fallback branch + assert.Contains(t, teamSQL, "WHERE l.team_ids IS NOT NULL AND l.team_ids IS JSON ARRAY") + assert.Contains(t, teamSQL, "SELECT l.team_id, COALESCE(l.team_name, '')") + assert.Contains(t, teamSQL, "WHERE l.team_ids IS NULL OR l.team_ids IS NOT JSON ARRAY") + assert.Contains(t, teamSQL, "UNION ALL") + assert.Contains(t, teamSQL, ") AS logs", "aliased AS logs so scope/filters resolve") + assert.Contains(t, teamSQL, "fan.dim_id AS dim_id") + assert.Contains(t, teamSQL, "fan.dim_name AS dim_name") + + buSQL, ok := teamOrBUFanoutFrom("business_unit_id") + require.True(t, ok) + assert.Contains(t, buSQL, "business_unit_ids::jsonb") + assert.Contains(t, buSQL, "SELECT l.business_unit_id, COALESCE(l.business_unit_name, '')") + + // Non-fan-out dimensions return false (caller uses the normal scalar path). + _, ok = teamOrBUFanoutFrom("user_id") + assert.False(t, ok) + _, ok = teamOrBUFanoutFrom("provider") + assert.False(t, ok) +} + +// TestCanUseMatViewFilters_ExcludesTeamBU verifies that a team or business-unit +// filter disqualifies the matview path: mv_logs_hourly only has the scalar +// primary, so these must fall through to the raw (array-or-scalar) path to stay +// complete. Other filters (e.g. provider) remain matview-eligible. +func TestCanUseMatViewFilters_ExcludesTeamBU(t *testing.T) { + assert.True(t, canUseMatViewFilters(SearchFilters{}), "empty filters → matview eligible") + assert.True(t, canUseMatViewFilters(SearchFilters{Providers: []string{"openai"}}), "provider filter stays matview-eligible") + + assert.False(t, canUseMatViewFilters(SearchFilters{TeamIDs: []string{"t1"}}), "team filter must force the raw path") + assert.False(t, canUseMatViewFilters(SearchFilters{BusinessUnitIDs: []string{"bu1"}}), "BU filter must force the raw path") +} diff --git a/framework/logstore/multi_team_matview_test.go b/framework/logstore/multi_team_matview_test.go new file mode 100644 index 00000000000..73d633caa6d --- /dev/null +++ b/framework/logstore/multi_team_matview_test.go @@ -0,0 +1,120 @@ +package logstore + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/maximhq/bifrost/framework/queryscope" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// insertTeamBULog inserts a single log row carrying the scalar and/or JSON-array +// team / business-unit columns. Empty-string args are stored as NULL so the +// matview's scalar-vs-array branch selection (team_ids IS JSON ARRAY) is +// exercised faithfully — a row with no array column must fall back to the scalar. +func insertTeamBULog(t *testing.T, db *gorm.DB, ts time.Time, + userID, teamID, teamName, teamIDs, teamNames, buID, buName, buIDs, buNames string) { + t.Helper() + nz := func(s string) any { + if s == "" { + return nil + } + return s + } + err := db.Exec(` + INSERT INTO logs (id, timestamp, object_type, provider, model, status, + user_id, team_id, team_name, team_ids, team_names, + business_unit_id, business_unit_name, business_unit_ids, business_unit_names, + created_at, latency, cost, prompt_tokens, completion_tokens, total_tokens) + VALUES (?, ?, 'chat_completion', 'openai', 'gpt-4', 'success', + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 100, 0.01, 10, 5, 15) + `, uuid.New().String(), ts, nz(userID), nz(teamID), nz(teamName), nz(teamIDs), nz(teamNames), + nz(buID), nz(buName), nz(buIDs), nz(buNames), ts).Error + require.NoError(t, err, "failed to insert team/BU test log") +} + +func keyPairsByID(pairs []KeyPairResult) map[string]string { + byID := make(map[string]string, len(pairs)) + for _, p := range pairs { + byID[p.ID] = p.Name + } + return byID +} + +// TestFilterTeamMatView_CollectsScalarAndArray is the regression for the reported +// bug: the team filter dropdown read mv_filter_teams, which only projected the +// scalar team_id/team_name. Teams that exist only in the JSON-array team_ids +// (enterprise user/AP path) were missing. The recreated view must surface both. +func TestFilterTeamMatView_CollectsScalarAndArray(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) // force the matview read path + ctx := context.Background() + now := time.Now().UTC() + + // Old / VK-team log: scalar team only, no JSON array. + insertTeamBULog(t, db, now, "u-1", "t-scalar", "Scalar Team", "", "", "", "", "", "") + // Enterprise log: teams only in the JSON array, scalar team NULL. + insertTeamBULog(t, db, now, "u-1", "", "", `["t-arr1","t-arr2"]`, `["Array One","Array Two"]`, "", "", "", "") + refreshTestMatViews(t, db) + + pairs, err := store.GetDistinctKeyPairs(ctx, "team_id", "team_name", 1000, "") + require.NoError(t, err) + byID := keyPairsByID(pairs) + + assert.Equal(t, "Scalar Team", byID["t-scalar"], "scalar-only team must still appear (backward compatibility)") + assert.Equal(t, "Array One", byID["t-arr1"], "array-only team must now appear") + assert.Equal(t, "Array Two", byID["t-arr2"], "second array team must appear, name aligned by ordinality") +} + +// TestFilterBusinessUnitMatView_CollectsScalarAndArray mirrors the team test for +// the business-unit dropdown (mv_filter_business_units), which had the identical +// scalar-only bug. +func TestFilterBusinessUnitMatView_CollectsScalarAndArray(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) + ctx := context.Background() + now := time.Now().UTC() + + insertTeamBULog(t, db, now, "u-1", "", "", "", "", "bu-scalar", "Scalar BU", "", "") + insertTeamBULog(t, db, now, "u-1", "", "", "", "", "", "", `["bu-arr1"]`, `["BU One"]`) + refreshTestMatViews(t, db) + + pairs, err := store.GetDistinctKeyPairs(ctx, "business_unit_id", "business_unit_name", 1000, "") + require.NoError(t, err) + byID := keyPairsByID(pairs) + + assert.Equal(t, "Scalar BU", byID["bu-scalar"], "scalar-only business unit must still appear") + assert.Equal(t, "BU One", byID["bu-arr1"], "array-only business unit must now appear") +} + +// TestFilterTeamMatView_DACScopeAppliesAfterFanout proves the visibility columns +// (user_id) survive the fan-out: each fanned-out team row carries its source +// log's user_id, so a QueryScope still both resolves (no "column does not exist") +// and filters — an array team owned by another user must not leak. +func TestFilterTeamMatView_DACScopeAppliesAfterFanout(t *testing.T) { + store, db := setupPerfTestDB(t) + store.matViewsReady.Store(true) + now := time.Now().UTC() + + // Array team owned by u-secret; scalar team visible to u-visible. + insertTeamBULog(t, db, now, "u-secret", "", "", `["t-secret"]`, `["Secret Team"]`, "", "", "", "") + insertTeamBULog(t, db, now, "u-visible", "t-public", "Public Team", "", "", "", "", "", "") + refreshTestMatViews(t, db) + + scope := queryscope.QueryScope(func(db *gorm.DB) *gorm.DB { + return db.Where("user_id = ?", "u-visible") + }) + ctx := queryscope.WithQueryScope(context.Background(), scope) + + pairs, err := store.GetDistinctKeyPairs(ctx, "team_id", "team_name", 1000, "") + require.NoError(t, err, "scope WHERE must resolve against the fanned-out matview columns") + byID := keyPairsByID(pairs) + + assert.Equal(t, "Public Team", byID["t-public"], "team visible to the scoped user must appear") + _, leaked := byID["t-secret"] + assert.False(t, leaked, "array team owned by another user must be filtered out by DAC scope") +} diff --git a/framework/logstore/postgres.go b/framework/logstore/postgres.go index b908765e976..7f1dd332bdc 100644 --- a/framework/logstore/postgres.go +++ b/framework/logstore/postgres.go @@ -197,6 +197,12 @@ func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger sch logger.Info("logstore: metadata GIN index is ready") } + if err := ensureMultiTeamBusinessUnitGINIndexes(context.Background(), lock.conn); err != nil { + logger.Warn(fmt.Sprintf("logstore: team/business-unit GIN index build failed: %s (filtering will still work without the index)", err)) + } else { + logger.Info("logstore: team/business-unit GIN indexes are ready") + } + if err := ensureDashboardEnhancements(context.Background(), lock.conn); err != nil { logger.Warn(fmt.Sprintf("logstore: dashboard enhancements failed: %s (dashboard will still work with partial data)", err)) } else { diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go index ce10cbcf4e7..d2c5bd3f8ad 100644 --- a/framework/logstore/rdb.go +++ b/framework/logstore/rdb.go @@ -85,6 +85,62 @@ func (s *RDBLogStore) ScopedDB(ctx context.Context) *gorm.DB { return db } +// multiValueDimensionFilterSQL builds a Postgres predicate matching logs by a +// dimension that is single-valued on the scalar column (the primary, set by the +// VK path / pre-migration rows) and multi-valued on the JSON-array column (the +// full set, set by the enterprise user/AP path). It ORs the scalar `IN` (btree +// index) with array containment per id (partial jsonb_path_ops GIN index). The +// `IS NOT NULL AND IS JSON ARRAY` guard matches the partial index predicate so +// the planner uses the GIN. Returns the parenthesised SQL and its args. +func multiValueDimensionFilterSQL(scalarCol, arrayCol string, ids []string) (string, []interface{}) { + arrConds := make([]string, len(ids)) + args := []interface{}{ids} + for i, id := range ids { + arrConds[i] = arrayCol + "::jsonb @> ?::jsonb" + frag, _ := sonic.Marshal([]string{id}) + args = append(args, string(frag)) + } + sql := fmt.Sprintf("(%s IN ? OR (%s IS NOT NULL AND %s IS JSON ARRAY AND (%s)))", + scalarCol, arrayCol, arrayCol, strings.Join(arrConds, " OR ")) + return sql, args +} + +// teamOrBUFanoutFrom returns a Postgres FROM subquery (aliased AS logs) that fans +// each log row out to one row per associated team / business unit, exposing +// derived `dim_id` and `dim_name` columns alongside all original log columns +// (l.*) so DAC scope and filters still resolve. Rows with the JSON-array column +// set are unnested (id+name aligned by ordinality); rows without it (pre-upgrade +// or VK-team logs) fall back to the scalar id/name — so historical logs keep +// contributing. The two branches are mutually exclusive, so no row is counted +// twice for the same dimension value. Returns ("", false) for non-fan-out +// dimensions. idCol is the scalar id column ("team_id" / "business_unit_id"), +// which both the ranking and histogram dimensions resolve to. No bind args: all +// identifiers are internal constants. +func teamOrBUFanoutFrom(idCol string) (string, bool) { + var arrIDs, arrNames, scalarName string + switch idCol { + case "team_id": + arrIDs, arrNames, scalarName = "team_ids", "team_names", "team_name" + case "business_unit_id": + arrIDs, arrNames, scalarName = "business_unit_ids", "business_unit_names", "business_unit_name" + default: + return "", false + } + return fmt.Sprintf(`( + SELECT l.*, fan.dim_id AS dim_id, fan.dim_name AS dim_name + FROM logs l + CROSS JOIN LATERAL ( + SELECT t.value AS dim_id, COALESCE(n.value, '') AS dim_name + FROM jsonb_array_elements_text(l.%[1]s::jsonb) WITH ORDINALITY AS t(value, ord) + LEFT JOIN jsonb_array_elements_text(l.%[2]s::jsonb) WITH ORDINALITY AS n(value, ord) ON n.ord = t.ord + WHERE l.%[1]s IS NOT NULL AND l.%[1]s IS JSON ARRAY + UNION ALL + SELECT l.%[3]s, COALESCE(l.%[4]s, '') + WHERE l.%[1]s IS NULL OR l.%[1]s IS NOT JSON ARRAY + ) AS fan +) AS logs`, arrIDs, arrNames, idCol, scalarName), true +} + // applyFilters applies search filters to a GORM query. Callers are // responsible for starting from ScopedDB(ctx) when row visibility // should be respected; this helper only adds the per-call filter @@ -121,7 +177,12 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g baseQuery = baseQuery.Where("routing_rule_id IN ?", filters.RoutingRuleIDs) } if len(filters.TeamIDs) > 0 { - baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + if s.db.Dialector.Name() == "postgres" { + sql, args := multiValueDimensionFilterSQL("team_id", "team_ids", filters.TeamIDs) + baseQuery = baseQuery.Where(sql, args...) + } else { + baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + } } if len(filters.CustomerIDs) > 0 { baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs) @@ -130,7 +191,12 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g baseQuery = baseQuery.Where("user_id IN ?", filters.UserIDs) } if len(filters.BusinessUnitIDs) > 0 { - baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + if s.db.Dialector.Name() == "postgres" { + sql, args := multiValueDimensionFilterSQL("business_unit_id", "business_unit_ids", filters.BusinessUnitIDs) + baseQuery = baseQuery.Where(sql, args...) + } else { + baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + } } if len(filters.RoutingEngineUsed) > 0 { // Query routing engines (comma-separated values) - find logs containing ANY of the specified engines @@ -2003,7 +2069,25 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi return nil, fmt.Errorf("invalid ranking dimension: %s", dimension) } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { + // Multi-valued team / business-unit dimensions fan out over the JSON array + // (with scalar fallback for old / VK-team logs) so a request credits every + // team/BU it touches. Postgres-only; this forces the live path — the + // matview-accelerated equivalent is deferred to the partitioning work. + fanoutFrom := "" + if s.db.Dialector.Name() == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(idCol); isFanout { + fanoutFrom = f + idCol, nameCol = "dim_id", "dim_name" + } + } + baseTable := func(q *gorm.DB) *gorm.DB { + if fanoutFrom != "" { + return q.Table(fanoutFrom) + } + return q.Model(&Log{}) + } + + if fanoutFrom == "" && s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) { return s.getDimensionRankingsFromMatView(ctx, filters, dimension) } @@ -2022,7 +2106,7 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi COALESCE(SUM(cost), 0) as total_cost `, idCol, nameExpr) - currentQuery := s.ScopedDB(ctx).Model(&Log{}) + currentQuery := baseTable(s.ScopedDB(ctx)) currentQuery = s.applyFilters(currentQuery, filters) currentQuery = currentQuery.Where("status IN ?", []string{"success", "error"}) currentQuery = currentQuery.Where(fmt.Sprintf("%s IS NOT NULL AND %s != ''", idCol, idCol)) @@ -2061,7 +2145,7 @@ func (s *RDBLogStore) GetDimensionRankings(ctx context.Context, filters SearchFi prevFilters.StartTime = &prevStart prevFilters.EndTime = &prevEnd - prevQuery := s.ScopedDB(ctx).Model(&Log{}) + prevQuery := baseTable(s.ScopedDB(ctx)) prevQuery = s.applyFilters(prevQuery, prevFilters) prevQuery = prevQuery.Where("status IN ?", []string{"success", "error"}) prevQuery = prevQuery.Where(fmt.Sprintf("%s IS NOT NULL AND %s != ''", idCol, idCol)) @@ -2663,12 +2747,29 @@ func (s *RDBLogStore) GetDimensionCostHistogram(ctx context.Context, filters Sea if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getDimensionCostHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) - } dimCol := string(dimension) dialect := s.db.Dialector.Name() - baseQuery := s.ScopedDB(ctx).Model(&Log{}) + // Team / business-unit dimensions fan out over the JSON array (scalar + // fallback for old / VK-team logs). Postgres-only; forces the live path. + // NOTE: under fan-out the per-bucket *total* cost is the attributed total + // (≥ real, since a shared request counts toward each of its teams/BUs); the + // per-dimension breakdown is exact. Surface it as "attributed" in the UI. + fanoutFrom := "" + if dialect == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(dimCol); isFanout { + fanoutFrom = f + dimCol = "dim_id" + } + } + if fanoutFrom == "" && dialect == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionCostHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + baseQuery := s.ScopedDB(ctx) + if fanoutFrom != "" { + baseQuery = baseQuery.Table(fanoutFrom) + } else { + baseQuery = baseQuery.Model(&Log{}) + } baseQuery = s.applyFilters(baseQuery, filters) baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) baseQuery = baseQuery.Where("cost IS NOT NULL AND cost > 0") @@ -2758,12 +2859,26 @@ func (s *RDBLogStore) GetDimensionTokenHistogram(ctx context.Context, filters Se if bucketSizeSeconds <= 0 { bucketSizeSeconds = 3600 } - if s.db.Dialector.Name() == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { - return s.getDimensionTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) - } dimCol := string(dimension) dialect := s.db.Dialector.Name() - baseQuery := s.ScopedDB(ctx).Model(&Log{}) + // Team / business-unit dimensions fan out over the JSON array (scalar + // fallback for old / VK-team logs). Postgres-only; forces the live path. + fanoutFrom := "" + if dialect == "postgres" { + if f, isFanout := teamOrBUFanoutFrom(dimCol); isFanout { + fanoutFrom = f + dimCol = "dim_id" + } + } + if fanoutFrom == "" && dialect == "postgres" && s.canUseMatView(filters) && bucketSizeSeconds >= 3600 { + return s.getDimensionTokenHistogramFromMatView(ctx, filters, bucketSizeSeconds, dimension) + } + baseQuery := s.ScopedDB(ctx) + if fanoutFrom != "" { + baseQuery = baseQuery.Table(fanoutFrom) + } else { + baseQuery = baseQuery.Model(&Log{}) + } baseQuery = s.applyFilters(baseQuery, filters) baseQuery = baseQuery.Where("status IN ?", []string{"success", "error"}) diff --git a/framework/logstore/rdb_postgres_perf_test.go b/framework/logstore/rdb_postgres_perf_test.go index 68674dcd3b9..ed2a27190e3 100644 --- a/framework/logstore/rdb_postgres_perf_test.go +++ b/framework/logstore/rdb_postgres_perf_test.go @@ -183,8 +183,14 @@ func insertPerfMCPLog(t *testing.T, db *gorm.DB, opts mcpLogOpts) { // refreshTestMatViews refreshes materialized views after inserting test data. // This is needed because matviews are populated at creation time and don't // automatically reflect new inserts until explicitly refreshed. +// +// The refresh gate is reset first: it's a package-level singleton, so without a +// reset a prior test leaves it initialized and the eventually-consistent +// pg_stat_user_tables counter (which lags fresh INSERTs by a few seconds) can +// make refreshMatViews short-circuit, leaving the matview stale for this test. func refreshTestMatViews(t *testing.T, db *gorm.DB) { t.Helper() + resetTestMatViewRefreshGate() ctx := context.Background() err := refreshMatViews(ctx, db) require.NoError(t, err, "Failed to refresh materialized views") diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index 0fa010be5a0..f5a1704f295 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -154,6 +154,10 @@ type Log struct { CustomerName *string `gorm:"type:varchar(255)" json:"customer_name"` BusinessUnitID *string `gorm:"type:varchar(255);index:idx_logs_business_unit_id" json:"business_unit_id"` BusinessUnitName *string `gorm:"type:varchar(255)" json:"business_unit_name"` + TeamIDs *string `gorm:"type:text" json:"-"` + TeamNames *string `gorm:"type:text" json:"-"` + BusinessUnitIDs *string `gorm:"type:text" json:"-"` + BusinessUnitNames *string `gorm:"type:text" json:"-"` InputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ChatMessage ResponsesInputHistory string `gorm:"type:text" json:"-"` // JSON serialized []schemas.ResponsesMessage OutputMessage string `gorm:"type:text" json:"-"` // JSON serialized *schemas.ChatMessage @@ -249,6 +253,10 @@ type Log struct { AttemptTrailParsed []schemas.KeyAttemptRecord `gorm:"-" json:"attempt_trail,omitempty"` BudgetIDsParsed []string `gorm:"-" json:"budget_ids,omitempty"` RateLimitIDsParsed []string `gorm:"-" json:"rate_limit_ids,omitempty"` + TeamIDsParsed []string `gorm:"-" json:"team_ids,omitempty"` + TeamNamesParsed []string `gorm:"-" json:"team_names,omitempty"` + BusinessUnitIDsParsed []string `gorm:"-" json:"business_unit_ids,omitempty"` + BusinessUnitNamesParsed []string `gorm:"-" json:"business_unit_names,omitempty"` // Populated in handlers after find using the virtual key id and key id VirtualKey *tables.TableVirtualKey `gorm:"-" json:"virtual_key,omitempty"` // redacted @@ -577,6 +585,39 @@ func (l *Log) SerializeFields() error { } } + if len(l.TeamIDsParsed) > 0 { + if data, err := sonic.Marshal(l.TeamIDsParsed); err != nil { + return err + } else { + s := string(data) + l.TeamIDs = &s + } + } + if len(l.TeamNamesParsed) > 0 { + if data, err := sonic.Marshal(l.TeamNamesParsed); err != nil { + return err + } else { + s := string(data) + l.TeamNames = &s + } + } + if len(l.BusinessUnitIDsParsed) > 0 { + if data, err := sonic.Marshal(l.BusinessUnitIDsParsed); err != nil { + return err + } else { + s := string(data) + l.BusinessUnitIDs = &s + } + } + if len(l.BusinessUnitNamesParsed) > 0 { + if data, err := sonic.Marshal(l.BusinessUnitNamesParsed); err != nil { + return err + } else { + s := string(data) + l.BusinessUnitNames = &s + } + } + // Build content summary for search. // Skip if already set (e.g., by the hybrid log store which builds input-only summaries). if l.ContentSummary == "" { @@ -813,6 +854,27 @@ func (l *Log) DeserializeFields() error { } } + if l.TeamIDs != nil && *l.TeamIDs != "" { + if err := sonic.Unmarshal([]byte(*l.TeamIDs), &l.TeamIDsParsed); err != nil { + l.TeamIDsParsed = nil + } + } + if l.TeamNames != nil && *l.TeamNames != "" { + if err := sonic.Unmarshal([]byte(*l.TeamNames), &l.TeamNamesParsed); err != nil { + l.TeamNamesParsed = nil + } + } + if l.BusinessUnitIDs != nil && *l.BusinessUnitIDs != "" { + if err := sonic.Unmarshal([]byte(*l.BusinessUnitIDs), &l.BusinessUnitIDsParsed); err != nil { + l.BusinessUnitIDsParsed = nil + } + } + if l.BusinessUnitNames != nil && *l.BusinessUnitNames != "" { + if err := sonic.Unmarshal([]byte(*l.BusinessUnitNames), &l.BusinessUnitNamesParsed); err != nil { + l.BusinessUnitNamesParsed = nil + } + } + if l.RoutingEnginesUsedStr != nil && *l.RoutingEnginesUsedStr != "" { // Parse comma-separated routing engines l.RoutingEnginesUsed = strings.Split(*l.RoutingEnginesUsedStr, ",") diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 6bc14ecf47e..78a84c4e999 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -160,6 +160,10 @@ type GovernanceStore interface { // Team level CheckUserBudget CheckTeamBudget(ctx context.Context, teamID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckTeamRateLimit(ctx context.Context, teamID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) + // Team-level live budget/rate-limit collectors (resolved from the hot maps); + // used by the enterprise user→team→business-unit hierarchy collector. + CollectTeamBudgets(ctx context.Context, teamID string) []*configstoreTables.TableBudget + CollectTeamRateLimits(ctx context.Context, teamID string) []*configstoreTables.TableRateLimit // Customer-level governance checks CheckCustomerBudget(ctx context.Context, customerID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckCustomerRateLimit(ctx context.Context, customerID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) @@ -1267,6 +1271,59 @@ func (gs *LocalGovernanceStore) CheckTeamRateLimit(ctx context.Context, teamID s return gs.CheckRateLimit(ctx, entityWiseRateLimits, tokensBaselines, requestsBaselines) } +// CollectTeamBudgets returns the live budget objects configured for a team, +// resolved by ID from the hot budgets map (so usage counters and recent edits +// are reflected). Mirrors the read pattern in CheckTeamBudget. Returns nil when +// the team is unknown or has no budgets. Exported so the enterprise layer can +// fold team budgets into a user→team→business-unit hierarchy collector the same +// way collectBudgetsFromHierarchy folds them into the VK hierarchy. +func (gs *LocalGovernanceStore) CollectTeamBudgets(ctx context.Context, teamID string) []*configstoreTables.TableBudget { + if teamID == "" { + return nil + } + teamValue, exists := gs.teams.Load(teamID) + if !exists || teamValue == nil { + return nil + } + team, ok := teamValue.(*configstoreTables.TableTeam) + if !ok || team == nil || len(team.Budgets) == 0 { + return nil + } + list := make([]*configstoreTables.TableBudget, 0, len(team.Budgets)) + for _, b := range team.Budgets { + if hot := gs.LoadBudget(ctx, b.ID); hot != nil { + list = append(list, hot) + } + } + if len(list) == 0 { + return nil + } + return list +} + +// CollectTeamRateLimits returns the live rate-limit object configured for a team +// (at most one), resolved by ID from the hot rate-limits map. Mirrors the read +// pattern in CheckTeamRateLimit. Returns nil when the team is unknown or has no +// rate limit. Exported for the enterprise user-hierarchy collector. +func (gs *LocalGovernanceStore) CollectTeamRateLimits(ctx context.Context, teamID string) []*configstoreTables.TableRateLimit { + if teamID == "" { + return nil + } + teamValue, exists := gs.teams.Load(teamID) + if !exists || teamValue == nil { + return nil + } + team, ok := teamValue.(*configstoreTables.TableTeam) + if !ok || team == nil || team.RateLimitID == nil { + return nil + } + rl := gs.LoadRateLimit(ctx, *team.RateLimitID) + if rl == nil { + return nil + } + return []*configstoreTables.TableRateLimit{rl} +} + // CheckCustomerBudget checks customer-level budget and returns evaluation result if violated func (gs *LocalGovernanceStore) CheckCustomerBudget(ctx context.Context, customerID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) { if customerID == "" { diff --git a/plugins/logging/main.go b/plugins/logging/main.go index 6b4e93b4a63..3b18e45d7cc 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -919,6 +919,18 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. if rateLimitIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceRateLimitIDs).([]string); ok && len(rateLimitIDs) > 0 { entry.RateLimitIDsParsed = rateLimitIDs } + if teamIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamIDs).([]string); ok && len(teamIDs) > 0 { + entry.TeamIDsParsed = teamIDs + } + if teamNames, ok := ctx.Value(schemas.BifrostContextKeyGovernanceTeamNames).([]string); ok && len(teamNames) > 0 { + entry.TeamNamesParsed = teamNames + } + if buIDs, ok := ctx.Value(schemas.BifrostContextKeyGovernanceBusinessUnitIDs).([]string); ok && len(buIDs) > 0 { + entry.BusinessUnitIDsParsed = buIDs + } + if buNames, ok := ctx.Value(schemas.BifrostContextKeyGovernanceBusinessUnitNames).([]string); ok && len(buNames) > 0 { + entry.BusinessUnitNamesParsed = buNames + } entry.MetadataParsed = pending.InitialData.Metadata entry.MetadataParsed = mergeRealtimeMetadata(entry.MetadataParsed, ctx) entry.RoutingEngineLogs = routingEngineLogs diff --git a/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx b/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx index 6ced24e0764..9bf990a6278 100644 --- a/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx +++ b/ui/app/workspace/dashboard/components/dimensionRankingsTab.tsx @@ -19,6 +19,7 @@ interface DimensionRankingsTabProps { loading: boolean; dimensionLabel: string; testIdPrefix: string; + attributed?: boolean; } function TopDimensionTooltip({ active, payload }: any) { @@ -38,11 +39,13 @@ function TopDimensionChart({ loading, dimensionLabel, testIdPrefix, + attributed, }: { data: DimensionRankingsResponse | null; loading: boolean; dimensionLabel: string; testIdPrefix: string; + attributed?: boolean; }) { const { chartData, grandTotal, rankedItems } = useMemo(() => { if (!data?.rankings?.length) return { chartData: [], grandTotal: null, rankedItems: [] }; @@ -73,20 +76,27 @@ function TopDimensionChart({ loading={loading} testId={`${testIdPrefix}-top-chart`} className="z-[1] h-full" - totalLabel="Total Requests" + totalLabel={attributed ? "Total Requests (attributed)" : "Total Requests"} total={grandTotal !== null ? : undefined} - totalTooltip={grandTotal !== null ? grandTotal.toLocaleString("en-US") : undefined} + totalTooltip={ + grandTotal === null ? undefined : attributed ? ( +
+
{grandTotal.toLocaleString("en-US")} requests
+
+ Attributed — a request counts toward each {dimensionLabel.toLowerCase()} it belongs to, so this can exceed the actual request + count. +
+
+ ) : ( + grandTotal.toLocaleString("en-US") + ) + } >
{chartData.length > 0 ? ( - + { const { x, y, payload } = props; const maxChars = 14; - const label = - payload.value.length > maxChars - ? `${payload.value.slice(0, maxChars)}…` - : payload.value; + const label = payload.value.length > maxChars ? `${payload.value.slice(0, maxChars)}…` : payload.value; return ( {payload.value} @@ -117,12 +124,7 @@ function TopDimensionChart({ width={92} /> } /> - + {chartData.map((entry, idx) => ( ))} @@ -155,7 +157,7 @@ function TopDimensionChart({ ); } -function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix }: DimensionRankingsTabProps) { +function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix, attributed }: DimensionRankingsTabProps) { const [sortField, setSortField] = useState("total_requests"); const [sortOrder, setSortOrder] = useState("desc"); @@ -187,6 +189,7 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix loading={loading} dimensionLabel={dimensionLabel} testIdPrefix={testIdPrefix} + attributed={attributed} /> {loading ? ( @@ -240,9 +243,7 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix
{entry.name || entry.id} - {entry.name && entry.name !== entry.id && ( - {entry.id} - )} + {entry.name && entry.name !== entry.id && {entry.id}}
@@ -273,4 +274,4 @@ function DimensionRankingsTabImpl({ data, loading, dimensionLabel, testIdPrefix ); } -export const DimensionRankingsTab = memo(DimensionRankingsTabImpl); +export const DimensionRankingsTab = memo(DimensionRankingsTabImpl); \ No newline at end of file diff --git a/ui/app/workspace/dashboard/components/tabViews/dimensionRankingsTabView.tsx b/ui/app/workspace/dashboard/components/tabViews/dimensionRankingsTabView.tsx index f5f2a5f806d..b6c1b6142f5 100644 --- a/ui/app/workspace/dashboard/components/tabViews/dimensionRankingsTabView.tsx +++ b/ui/app/workspace/dashboard/components/tabViews/dimensionRankingsTabView.tsx @@ -40,6 +40,14 @@ export const DimensionRankingsTabView = forwardRef; + return ( + + ); }, ); diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 8b7c1f3954e..934b5364255 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -927,19 +927,28 @@ export function LogDetailView({ {log.number_of_retries > 0 && ( )} - {log.team_id && ( + {(log.team_ids?.length || log.team_id) && ( 1 ? "Teams" : "Team"} value={ - - {log.team_name || log.team_id} - + + {(log.team_ids?.length + ? log.team_ids.map((id, i) => ({ id, name: log.team_names?.[i] || id })) + : [{ id: log.team_id!, name: log.team_name || log.team_id! }] + ).map((t, i, arr) => ( + + {t.name} + {i < arr.length - 1 ? "," : ""} + + ))} + } /> )} @@ -959,19 +968,28 @@ export function LogDetailView({ } /> )} - {log.business_unit_id && ( + {(log.business_unit_ids?.length || log.business_unit_id) && ( 1 ? "Business Units" : "Business Unit"} value={ - - {log.business_unit_name || log.business_unit_id} - + + {(log.business_unit_ids?.length + ? log.business_unit_ids.map((id, i) => ({ id, name: log.business_unit_names?.[i] || id })) + : [{ id: log.business_unit_id!, name: log.business_unit_name || log.business_unit_id! }] + ).map((b, i, arr) => ( + + {b.name} + {i < arr.length - 1 ? "," : ""} + + ))} + } /> )} diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts index c338cafbc3f..8ee0f042722 100644 --- a/ui/lib/types/logs.ts +++ b/ui/lib/types/logs.ts @@ -518,6 +518,10 @@ export interface LogEntry { customer_id?: string; business_unit_id?: string; business_unit_name?: string; + team_ids?: string[]; + team_names?: string[]; + business_unit_ids?: string[]; + business_unit_names?: string[]; user_id?: string; user_name?: string; virtual_key_id?: string;