Skip to content
Open
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
12 changes: 12 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions internal/admin/handler_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ 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 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 @@ -147,6 +148,7 @@ func parseAuditLogQueryParams(c *echo.Context) (auditlog.LogQueryParams, error)
Method: strings.ToUpper(c.QueryParam("method")),
Path: c.QueryParam("path"),
UserPath: userPath,
RequestID: strings.TrimSpace(c.QueryParam("request_id")),
SessionID: sessionID,
ErrorType: c.QueryParam("error_type"),
Search: c.QueryParam("search"),
Expand Down Expand Up @@ -207,6 +209,7 @@ 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 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
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
1 change: 1 addition & 0 deletions internal/auditlog/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type LogQueryParams struct {
Method string
Path string
UserPath string
RequestID string // exact-match request id filter
SessionID string // exact-match session id filter
ErrorType string
Search string
Expand Down
8 changes: 7 additions & 1 deletion internal/auditlog/reader_mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,10 @@ func mongoLogMatchFilters(params LogQueryParams) (bson.D, error) {
if params.ExactUserPath {
matchFilters = append(matchFilters, mongoExactUserPathMatchFilter(userPath))
} else {
matchFilters = append(matchFilters, mongoUserPathMatchFilter(userPath))
matchFilters = append(matchFilters, bson.E{Key: "user_path", Value: bson.D{
{Key: "$regex", Value: regexp.QuoteMeta(userPath)},
{Key: "$options", Value: "i"},
}})
}
}
if params.ErrorType != "" {
Expand All @@ -274,6 +277,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
11 changes: 11 additions & 0 deletions internal/auditlog/reader_mongodb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,14 @@ 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)
}
}
9 changes: 7 additions & 2 deletions internal/auditlog/reader_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,18 @@ func (r *SQLReader) logFilters(ctx context.Context, params LogQueryParams) ([]st
if params.ExactUserPath {
add(auditExactUserPathSQLPredicate(userPath, r.dialect.userPath), userPath)
} else {
lower, upper := auditUserPathSubtreeBounds(userPath)
add(auditUserPathSQLPredicate(userPath, r.dialect.userPath), userPath, lower, upper)
// The dashboard field filter is an incremental text search. Keep
// matching useful while a path is still being typed instead of
// treating the fragment as a complete hierarchy node.
add(r.likeClause("user_path"), contains(userPath))
}
}
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
57 changes: 57 additions & 0 deletions internal/auditlog/reader_sql_boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,33 @@ 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",
}}); 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)
}
result, err := reader.GetLogs(context.Background(), LogQueryParams{UserPath: "alpha", Limit: 10})
if err != nil {
t.Fatalf("GetLogs returned error: %v", err)
}
if result.Total != 1 || result.Entries[0].ID != "partial-user-path" {
t.Fatalf("partial user path returned %#v, want partial-user-path", result.Entries)
}
})
}

// 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 @@ -202,6 +229,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
10 changes: 10 additions & 0 deletions web/dashboard/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@
"audit_loading": "Loading audit logs",
"audit_search_placeholder": "Search by request ID, model, provider, path, user path, or error…",
"audit_search_label": "Search by request ID, model, provider, path, user path, or error",
"audit_field_value_placeholder": "Enter a value to filter",
"audit_field_value_label": "Audit field value",
"audit_filter_field_label": "Audit filter field",
"audit_filter_field_user_path": "User path",
"audit_filter_field_request_id": "Request ID",
"audit_filter_field_model": "Model",
"audit_filter_field_provider": "Provider",
"audit_filter_field_session_id": "Session ID",
"audit_filter_field_error_type": "Error type",
"audit_filter_field_search": "All fields",
"audit_filter_method_label": "HTTP method filter",
"audit_filter_all_methods": "All Methods",
"audit_filter_status_label": "Status code filter",
Expand Down
10 changes: 10 additions & 0 deletions web/dashboard/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@
"audit_loading": "Wczytywanie logów audytowych",
"audit_search_placeholder": "Szukaj według ID Requestu, modelu, dostawcy, ścieżki, User Path lub błędu…",
"audit_search_label": "Szukaj według ID Requestu, modelu, dostawcy, ścieżki, User Path lub błędu",
"audit_field_value_placeholder": "Wpisz wartość filtra",
"audit_field_value_label": "Wartość pola audytu",
"audit_filter_field_label": "Pole filtra audytu",
"audit_filter_field_user_path": "Ścieżka użytkownika",
"audit_filter_field_request_id": "ID żądania",
"audit_filter_field_model": "Model",
"audit_filter_field_provider": "Dostawca",
"audit_filter_field_session_id": "ID sesji",
"audit_filter_field_error_type": "Typ błędu",
"audit_filter_field_search": "Wszystkie pola",
"audit_filter_method_label": "Filtr metody HTTP",
"audit_filter_all_methods": "Wszystkie metody",
"audit_filter_status_label": "Filtr kodu statusu",
Expand Down
29 changes: 23 additions & 6 deletions web/dashboard/src/pages/audit-logs/AuditFilters.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script>
// Audit-log toolbar: consolidated search + method/status/stream selects and
// the Clear button.
// Audit-log toolbar: explicit field filter + method/status/stream selects.
import Icon from "$lib/components/atoms/Icon.svelte";
import FilterInput from "$lib/components/molecules/FilterInput.svelte";
import { debounced } from "$lib/utils/debounce.js";
Expand All @@ -16,12 +15,28 @@
<div class="audit-filter-row audit-filter-row-search">
<FilterInput
id="audit-filter-search"
placeholder={m.audit_search_placeholder()}
label={m.audit_search_label()}
bind:value={auditList.auditSearch}
placeholder={m.audit_field_value_placeholder()}
label={m.audit_field_value_label()}
title={m.audit_search_label() || m.audit_search_placeholder()}
bind:value={auditList.auditFieldValue}
oninput={onSearchInput}
loading={auditList.loading}
/>
<select
id="audit-filter-field"
aria-label={m.audit_filter_field_label()}
class="usage-log-select audit-filter-select audit-filter-field"
bind:value={auditList.auditField}
onchange={() => auditList.fetchAuditLog(true)}
>
<option value="user_path">{m.audit_filter_field_user_path()}</option>
<option value="request_id">{m.audit_filter_field_request_id()}</option>
<option value="model">{m.audit_filter_field_model()}</option>
<option value="provider">{m.audit_filter_field_provider()}</option>
<option value="session_id">{m.audit_filter_field_session_id()}</option>
<option value="error_type">{m.audit_filter_field_error_type()}</option>
<option value="search">{m.audit_filter_field_search()}</option>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</select>
</div>
<div class="audit-filter-row audit-filter-row-controls">
<select
Expand Down Expand Up @@ -100,10 +115,12 @@
}

.audit-filter-row-search :global(.filter-input-wrap) {
grid-column: 1 / -1;
grid-column: span 8;
max-width: none;
}

.audit-filter-field { grid-column: span 4; width: 100%; }

.audit-filter-row-controls .audit-filter-select {
grid-column: span 2;
}
Expand Down
15 changes: 15 additions & 0 deletions web/dashboard/src/pages/audit-logs/audit-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,27 @@ export function buildAuditLogQuery({
method,
statusCode,
stream,
field,
fieldValue,
}) {
let qs = dateQuery;
qs += "&limit=" + limit + "&offset=" + offset;
if (search) qs += "&search=" + encodeURIComponent(search);
if (method) qs += "&method=" + encodeURIComponent(method);
if (statusCode) qs += "&status_code=" + encodeURIComponent(statusCode);
if (stream) qs += "&stream=" + encodeURIComponent(stream);
if (fieldValue) {
const param = {
user_path: "user_path",
request_id: "request_id",
model: "requested_model",
provider: "provider",
session_id: "session_id",
error_type: "error_type",
search: "search",
}[field] || "search";
qs += "&" + param + "=" + encodeURIComponent(fieldValue);
}
return qs;
}

Expand Down Expand Up @@ -303,6 +317,7 @@ export function auditLogAllowsLiveEntries(payload, filters) {
!(filters && filters.method) &&
!(filters && filters.statusCode) &&
!(filters && filters.stream) &&
!(filters && filters.fieldValue) &&
auditLiveDateRangeAllowsNow(filters)
);
}
Expand Down
Loading