Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
da88fe1
feat(auditlog): add field-specific audit filters
mikemikimike Aug 22, 2026
1e7d3c1
fix(auditlog): address field filter review feedback
mikemikimike Aug 22, 2026
36feff4
fix(ci): refresh embedded dashboard assets
mikemikimike Aug 24, 2026
b5acd17
fix(auditlog): align default field filter layout
mikemikimike Aug 24, 2026
82ff1b4
feat(auditlog): add field-specific audit filters
mikemikimike Aug 22, 2026
d913dbe
fix(auditlog): address field filter review feedback
mikemikimike Aug 22, 2026
1641b04
fix(ci): refresh embedded dashboard assets
mikemikimike Aug 24, 2026
8e38d30
fix(auditlog): align default field filter layout
mikemikimike Aug 24, 2026
7c18c3b
fix(auditlog): simplify all-fields label
mikemikimike Aug 24, 2026
479dc6d
fix(ci): regenerate dashboard assets after rebase
mikemikimike Aug 24, 2026
25bacd3
chore: preserve PR branch history after rebase
mikemikimike Aug 24, 2026
d76d7fd
fix(auditlog): filter request IDs exactly
mikemikimike Aug 24, 2026
992b4f6
fix(auditlog): support partial user path filters
mikemikimike Aug 26, 2026
3e709e0
Merge branch 'main' into fix/audit-field-filters
mikemikimike Aug 27, 2026
c0acf0d
fix(auditlog): support incremental user path search
mikemikimike Aug 27, 2026
7f39d67
Merge remote-tracking branch 'upstream/main' into codex/gomodel-737-p…
mikemikimike Aug 27, 2026
3f3ddc3
docs: add audit filter verification recording
mikemikimike Aug 28, 2026
5e06012
fix(auditlog): index incremental user path filters
mikemikimike Aug 29, 2026
8630053
Merge latest upstream main and address audit filter review`n`nUse can…
mikemikimike Aug 30, 2026
c67a432
fix(auditlog): address dashboard filter review
mikemikimike Aug 30, 2026
ceed2da
Merge latest upstream main
mikemikimike Aug 30, 2026
6b4f419
fix(dashboard): remove unused failover translation keys
mikemikimike Sep 2, 2026
5a7c811
fix(dashboard): remove unused failover messages
mikemikimike Sep 2, 2026
27143e6
fix(dashboard): remove unused model messages
mikemikimike Sep 2, 2026
bde756b
fix(dashboard): keep plural failover messages
mikemikimike Sep 2, 2026
61bc8f6
fix(dashboard): remove unused failover translations
mikemikimike Sep 2, 2026
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
Binary file added audit-filter-real-preview-v3.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
407 changes: 354 additions & 53 deletions cmd/gomodel/docs/docs.go

Large diffs are not rendered by default.

518 changes: 457 additions & 61 deletions docs/openapi.json

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions internal/admin/handler_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const conversationBuildTimeout = 10 * time.Second
// @Param method query string false "Filter by HTTP method"
// @Param path query string false "Filter by request path"
// @Param user_path query string false "Filter by tracked user path subtree"
// @Param user_path_search query string false "Case-insensitive substring search on the tracked user path"
// @Param request_id query string false "Filter by exact request id"
// @Param session_id query string false "Filter by exact session id"
// @Param error_type query string false "Filter by error type"
// @Param status_code query int false "Filter by status code"
Expand Down Expand Up @@ -134,6 +136,7 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
if err != nil {
return params, err
}
userPathSearch := strings.TrimSpace(c.QueryParam("user_path_search"))

requestedModel := c.QueryParam("requested_model")
if requestedModel == "" {
Expand All @@ -147,6 +150,8 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
Method: strings.ToUpper(c.QueryParam("method")),
Path: c.QueryParam("path"),
UserPath: userPath,
UserPathSearch: userPathSearch,
RequestID: strings.TrimSpace(c.QueryParam("request_id")),
SessionID: sessionID,
ErrorType: c.QueryParam("error_type"),
Search: c.QueryParam("search"),
Expand Down Expand Up @@ -207,6 +212,8 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
// @Param method query string false "Filter by HTTP method"
// @Param path query string false "Filter by request path"
// @Param user_path query string false "Filter by tracked user path subtree"
// @Param user_path_search query string false "Case-insensitive substring search on the tracked user path"
// @Param request_id query string false "Filter by exact request id"
// @Param error_type query string false "Filter by error type"
// @Param status_code query int false "Filter by status code"
// @Param stream query bool false "Filter by stream mode (true/false)"
Expand Down Expand Up @@ -348,11 +355,13 @@ func (h *Handler) AuditStats(c *echo.Context) error {

_, location := dashboardTimeZone(c)
params := auditlog.RequestStatsParams{
StartDate: dateRange.StartDate,
EndDate: dateRange.EndDate,
Interval: interval,
Location: location,
Now: timeNow(),
QueryParams: auditlog.QueryParams{
StartDate: dateRange.StartDate,
EndDate: dateRange.EndDate,
},
Interval: interval,
Location: location,
Now: timeNow(),
}

stats, err := h.auditReader.GetRequestStats(c.Request().Context(), params)
Expand Down
5 changes: 4 additions & 1 deletion internal/admin/handler_audit_sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestAuditSessions_Success(t *testing.T) {
}

h := NewHandler(nil, nil, WithAuditReader(reader))
c, rec := newHandlerContext("/admin/audit/sessions?days=7&user_path=/team")
c, rec := newHandlerContext("/admin/audit/sessions?days=7&user_path=/team&user_path_search=xinat")

if err := h.AuditSessions(c); err != nil {
t.Fatalf("unexpected error: %v", err)
Expand All @@ -76,6 +76,9 @@ func TestAuditSessions_Success(t *testing.T) {
if reader.lastQuery.UserPath != "/team" {
t.Errorf("user_path filter not forwarded: %q", reader.lastQuery.UserPath)
}
if reader.lastQuery.UserPathSearch != "xinat" {
t.Errorf("user_path_search filter not forwarded: %q", reader.lastQuery.UserPathSearch)
}

var result struct {
Sessions []struct {
Expand Down
5 changes: 4 additions & 1 deletion internal/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,7 @@ func TestAuditLog_WithFilters(t *testing.T) {
}

h := NewHandler(nil, nil, WithAuditReader(reader))
c, rec := newHandlerContext("/admin/audit/log?model=gpt-4&provider=openai&method=post&path=/v1/chat/completions&user_path=/team&error_type=provider_error&status_code=502&stream=true&search=timeout&limit=10&offset=5")
c, rec := newHandlerContext("/admin/audit/log?model=gpt-4&provider=openai&method=post&path=/v1/chat/completions&user_path=/team&request_id=req-42&error_type=provider_error&status_code=502&stream=true&search=timeout&limit=10&offset=5")

if err := h.AuditLog(c); err != nil {
t.Fatalf("unexpected error: %v", err)
Expand All @@ -1454,6 +1454,9 @@ func TestAuditLog_WithFilters(t *testing.T) {
if reader.lastQuery.UserPath != "/team" {
t.Errorf("expected user_path filter to match, got %q", reader.lastQuery.UserPath)
}
if reader.lastQuery.RequestID != "req-42" {
t.Errorf("expected request_id filter req-42, got %q", reader.lastQuery.RequestID)
}
if reader.lastQuery.ErrorType != "provider_error" {
t.Errorf("expected error_type provider_error, got %q", reader.lastQuery.ErrorType)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/auditlog/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ type LogQueryParams struct {
Method string
Path string
UserPath string
// UserPathSearch is a case-insensitive substring filter used by the
// dashboard's incremental user-path search. UserPath keeps its canonical
// exact/subtree semantics for API callers.
UserPathSearch string
RequestID string // exact-match request id filter
SessionID string // exact-match session id filter
ErrorType string
Search string
Expand Down
9 changes: 9 additions & 0 deletions internal/auditlog/reader_mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ func mongoLogMatchFilters(params LogQueryParams) (bson.D, error) {
matchFilters = append(matchFilters, mongoUserPathMatchFilter(userPath))
}
}
if userPathSearch := strings.TrimSpace(params.UserPathSearch); userPathSearch != "" {
matchFilters = append(matchFilters, bson.E{Key: "user_path", Value: bson.D{
{Key: "$regex", Value: regexp.QuoteMeta(userPathSearch)},
{Key: "$options", Value: "i"},
}})
}
if params.ErrorType != "" {
matchFilters = append(matchFilters, bson.E{
Key: "error_type",
Expand All @@ -274,6 +280,9 @@ func mongoLogMatchFilters(params LogQueryParams) (bson.D, error) {
},
})
}
if params.RequestID != "" {
matchFilters = append(matchFilters, bson.E{Key: "request_id", Value: params.RequestID})
}
if params.SessionID != "" {
matchFilters = append(matchFilters, bson.E{Key: "session_id", Value: params.SessionID})
}
Expand Down
25 changes: 25 additions & 0 deletions internal/auditlog/reader_mongodb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,28 @@ func TestMongoExactUserPathMatchFilter(t *testing.T) {
}
})
}

func TestMongoLogMatchFilters_RequestIDUsesExactFieldMatch(t *testing.T) {
got, err := mongoLogMatchFilters(LogQueryParams{RequestID: "req-42"})
if err != nil {
t.Fatalf("mongoLogMatchFilters returned error: %v", err)
}
want := bson.D{{Key: "request_id", Value: "req-42"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("mongoLogMatchFilters(request_id) = %#v, want %#v", got, want)
}
}

func TestMongoLogMatchFilters_UserPathSearchUsesSubstringRegex(t *testing.T) {
got, err := mongoLogMatchFilters(LogQueryParams{UserPathSearch: "xinat"})
if err != nil {
t.Fatalf("mongoLogMatchFilters returned error: %v", err)
}
want := bson.D{{Key: "user_path", Value: bson.D{
{Key: "$regex", Value: "xinat"},
{Key: "$options", Value: "i"},
}}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("mongoLogMatchFilters(user_path_search) = %#v, want %#v", got, want)
}
}
6 changes: 6 additions & 0 deletions internal/auditlog/reader_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,15 @@ func (r *SQLReader) logFilters(ctx context.Context, params LogQueryParams) ([]st
add(auditUserPathSQLPredicate(userPath, r.dialect.userPath), userPath, lower, upper)
}
}
if userPathSearch := strings.TrimSpace(params.UserPathSearch); userPathSearch != "" {
add(r.likeClause("user_path"), contains(userPathSearch))
}
if params.ErrorType != "" {
add(r.likeClause("error_type"), contains(params.ErrorType))
}
if params.RequestID != "" {
add("request_id = ?", params.RequestID)
}
if params.SessionID != "" {
add("session_id = ?", params.SessionID)
}
Expand Down
94 changes: 90 additions & 4 deletions internal/auditlog/reader_sql_boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ func TestSQLReaderGetLogs_IncludesFractionalStartBoundaryAndExcludesFractionalEn
}

result, err := reader.GetLogs(ctx, LogQueryParams{
StartDate: time.Date(2026, 1, 16, 0, 0, 0, 0, location),
EndDate: time.Date(2026, 1, 16, 0, 0, 0, 0, location),
Limit: 10,
Offset: 0,
QueryParams: QueryParams{
StartDate: time.Date(2026, 1, 16, 0, 0, 0, 0, location),
EndDate: time.Date(2026, 1, 16, 0, 0, 0, 0, location),
},
Limit: 10,
Offset: 0,
})
if err != nil {
t.Fatalf("GetLogs returned error: %v", err)
Expand Down Expand Up @@ -131,6 +133,60 @@ func TestSQLReaderGetLogs_SearchMatchesUserPath(t *testing.T) {
})
}

func TestSQLReaderGetLogs_UserPathFilterMatchesPartialPath(t *testing.T) {
sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
store, err := newSQLStoreForTest(t, db, 0)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
if err := store.WriteBatch(context.Background(), []*LogEntry{
{
ID: "partial-user-path", Timestamp: time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC),
RequestedModel: "gpt-5", Provider: "openai", UserPath: "/team/alpha",
},
{
ID: "video-user-path", Timestamp: time.Date(2026, 1, 16, 11, 0, 0, 0, time.UTC),
RequestedModel: "gpt-5", Provider: "openai", UserPath: "/voxinate/events/97gn9a9e",
},
}); err != nil {
t.Fatalf("failed to seed audit log: %v", err)
}

reader, err := NewSQLReader(db)
if err != nil {
t.Fatalf("failed to create reader: %v", err)
}

for _, tc := range []struct {
name, query, wantID string
}{
{name: "complete segment", query: "alpha", wantID: "partial-user-path"},
{name: "video prefix", query: "voxina", wantID: "video-user-path"},
{name: "word fragment", query: "xinat", wantID: "video-user-path"},
} {
t.Run(tc.name, func(t *testing.T) {
result, err := reader.GetLogs(context.Background(), LogQueryParams{UserPathSearch: tc.query, Limit: 10})
if err != nil {
t.Fatalf("GetLogs returned error: %v", err)
}
if result.Total != 1 || len(result.Entries) != 1 || result.Entries[0].ID != tc.wantID {
t.Fatalf("partial user path %q returned %#v (total %d), want %s", tc.query, result.Entries, result.Total, tc.wantID)
}
})
}

sessions, err := reader.GetSessions(context.Background(), LogQueryParams{
UserPathSearch: "voxina", Limit: 10,
})
if err != nil {
t.Fatalf("GetSessions returned error: %v", err)
}
if sessions.Total != 1 || len(sessions.Sessions) != 1 || sessions.Sessions[0].Latest.ID != "video-user-path" {
t.Fatalf("partial user path sessions = %#v (total %d), want video-user-path", sessions.Sessions, sessions.Total)
}
})
}

// A full canonical UUID takes the indexed-identifier fast path: equality on
// id/request_id/auth_key_id/session_id, case-insensitively — and deliberately
// no longer the LIKE sweep over the free-text columns.
Expand Down Expand Up @@ -200,6 +256,36 @@ func TestSQLReaderGetLogs_SearchUUIDMatchesIdentifierColumns(t *testing.T) {
})
}

func TestSQLReaderGetLogs_RequestIDMatchesOnlyRequestID(t *testing.T) {
sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {
store, err := newSQLStoreForTest(t, db, 0)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}

ctx := context.Background()
if err := store.WriteBatch(ctx, []*LogEntry{
{ID: "request-match", Timestamp: time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC), Provider: "openai", RequestID: "req-42"},
{ID: "session-only", Timestamp: time.Date(2026, 1, 16, 11, 0, 0, 0, time.UTC), Provider: "openai", SessionID: "req-42"},
{ID: "model-only", Timestamp: time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC), Provider: "openai", RequestedModel: "req-42"},
}); err != nil {
t.Fatalf("failed to seed audit logs: %v", err)
}

reader, err := NewSQLReader(db)
if err != nil {
t.Fatalf("failed to create reader: %v", err)
}
result, err := reader.GetLogs(ctx, LogQueryParams{RequestID: "req-42", Limit: 10})
if err != nil {
t.Fatalf("GetLogs returned error: %v", err)
}
if result.Total != 1 || len(result.Entries) != 1 || result.Entries[0].ID != "request-match" {
t.Fatalf("request_id filter returned total=%d entries=%v, want only request-match", result.Total, result.Entries)
}
})
}

func TestSQLReaderGetLogs_SearchMatchesErrorMessage(t *testing.T) {
sqlxtest.Run(t, func(t *testing.T, db sqlx.DB) {

Expand Down
30 changes: 30 additions & 0 deletions internal/auditlog/search_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var searchColumns = []string{
}

const trigramSearchIndex = "idx_audit_search_trgm"
const userPathTrigramSearchIndex = "idx_audit_user_path_trgm"

// minTrigramSearchLength is the shortest term, in characters, that yields a
// trigram.
Expand Down Expand Up @@ -70,6 +71,7 @@ func ensureTrigramSearchIndex(ctx context.Context, db sqlx.DB, errorMessage stri
}
}
if hasTrigramSearchIndex(ctx, db) {
ensureUserPathTrigramSearchIndex(ctx, db, schema)
return
}
statement := fmt.Sprintf(`CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON audit_logs USING GIN (%s %s.gin_trgm_ops)`,
Expand All @@ -80,6 +82,25 @@ func ensureTrigramSearchIndex(ctx context.Context, db sqlx.DB, errorMessage stri
return
}
slog.Info("auditlog: built trigram search index", "duration", time.Since(started).Round(time.Millisecond))
ensureUserPathTrigramSearchIndex(ctx, db, schema)
}

func ensureUserPathTrigramSearchIndex(ctx context.Context, db sqlx.DB, schema string) {
var valid *bool
if err := db.QueryRow(ctx, `SELECT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass(?)`, userPathTrigramSearchIndex).Scan(&valid); err == nil && valid != nil && !*valid {
slog.Warn("auditlog: rebuilding interrupted user path trigram index")
if _, err := db.Exec(ctx, "DROP INDEX "+userPathTrigramSearchIndex); err != nil {
slog.Warn("auditlog: failed to drop invalid user path trigram index", "error", err)
return
}
}
if hasUserPathTrigramSearchIndex(ctx, db) {
return
}
statement := fmt.Sprintf("CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON audit_logs USING GIN (user_path %s.gin_trgm_ops)", userPathTrigramSearchIndex, `"`+strings.ReplaceAll(schema, `"`, `""`)+`"`)
if _, err := db.Exec(ctx, statement); err != nil {
slog.Warn("auditlog: failed to create user path trigram index", "error", err)
}
}

// hasTrigramSearchIndex reports whether the trigram index exists and is
Expand All @@ -92,3 +113,12 @@ func hasTrigramSearchIndex(ctx context.Context, db sqlx.DB) bool {
err := db.QueryRow(ctx, `SELECT COALESCE(i.indisvalid, FALSE) FROM pg_index i WHERE i.indexrelid = to_regclass(?)`, trigramSearchIndex).Scan(&valid)
return err == nil && valid
}

func hasUserPathTrigramSearchIndex(ctx context.Context, db sqlx.DB) bool {
if db.Dialect() != sqlx.PostgreSQL {
return false
}
var valid bool
err := db.QueryRow(ctx, `SELECT COALESCE(i.indisvalid, FALSE) FROM pg_index i WHERE i.indexrelid = to_regclass(?)`, userPathTrigramSearchIndex).Scan(&valid)
return err == nil && valid
}
3 changes: 3 additions & 0 deletions internal/auditlog/search_index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func requireTrigramIndex(t *testing.T, db sqlx.DB) (*SQLStore, *SQLReader) {
if !hasTrigramSearchIndex(ctx, db) {
t.Skip("pg_trgm could not be installed on the test server")
}
if !hasUserPathTrigramSearchIndex(ctx, db) {
t.Fatal("user path trigram index was not created")
}
reader, err := NewSQLReader(db)
if err != nil {
t.Fatalf("failed to create reader: %v", err)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
data: {"candidates": [{"content": {"parts": [{"text": "Hello"}],"role": "model"},"index": 0}],"usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 1,"totalTokenCount": 30,"promptTokensDetails": [{"modality": "TEXT","tokenCount": 11}],"thoughtsTokenCount": 18,"serviceTier": "standard"},"modelVersion": "gemini-2.5-flash","responseId": "cWmNaqOEH9CRkdUP1bWcsQU"}
data: {"candidates": [{"content": {"parts": [{"text": ", World!"}],"role": "model"},"finishReason": "STOP","index": 0}],"usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 4,"totalTokenCount": 33,"promptTokensDetails": [{"modality": "TEXT","tokenCount": 11}],"thoughtsTokenCount": 18,"serviceTier": "standard"},"modelVersion": "gemini-2.5-flash","responseId": "cWmNaqOEH9CRkdUP1bWcsQU"}
data: {"candidates": [{"content": {"parts": [{"text": "Hello"}],"role": "model"},"index": 0}],"usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 1,"totalTokenCount": 30,"promptTokensDetails": [{"modality": "TEXT","tokenCount": 11}],"thoughtsTokenCount": 18,"serviceTier": "standard"},"modelVersion": "gemini-2.5-flash","responseId": "cWmNaqOEH9CRkdUP1bWcsQU"}

data: {"candidates": [{"content": {"parts": [{"text": ", World!"}],"role": "model"},"finishReason": "STOP","index": 0}],"usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 4,"totalTokenCount": 33,"promptTokensDetails": [{"modality": "TEXT","tokenCount": 11}],"thoughtsTokenCount": 18,"serviceTier": "standard"},"modelVersion": "gemini-2.5-flash","responseId": "cWmNaqOEH9CRkdUP1bWcsQU"}
Empty file modified tests/e2e/manage-release-e2e-stack.sh
100755 → 100644
Empty file.
Empty file modified tests/e2e/run-release-e2e.sh
100755 → 100644
Empty file.
Empty file modified tests/e2e/test-iac-virtualmodels.sh
100755 → 100644
Empty file.
Empty file modified tests/e2e/upgrade-compat.sh
100755 → 100644
Empty file.
Empty file modified tools/seed-demo-data.sh
100755 → 100644
Empty file.
Loading