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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 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.

1 change: 1 addition & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ storage:
logging:
enabled: true
log_bodies: true # WARNING: may contain sensitive data
log_revision_bodies: true # store rewritten bodies from request rewriters (needs log_bodies)
log_headers: true
buffer_size: 1000
flush_interval: 5 # seconds
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func buildDefaultConfig() *Config {
Logging: LogConfig{
Enabled: true,
LogBodies: true,
LogRevisionBodies: true,
LogHeaders: true,
BufferSize: 1000,
FlushInterval: 5,
Expand Down
2 changes: 1 addition & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func clearAllConfigEnvVars(t *testing.T) {
"STORAGE_TYPE", "SQLITE_PATH", "POSTGRES_URL", "POSTGRES_MAX_CONNS",
"MONGODB_URL", "MONGODB_DATABASE",
"METRICS_ENABLED", "METRICS_ENDPOINT",
"LOGGING_ENABLED", "LOGGING_LOG_BODIES", "LOGGING_LOG_HEADERS",
"LOGGING_ENABLED", "LOGGING_LOG_BODIES", "LOGGING_LOG_REVISION_BODIES", "LOGGING_LOG_HEADERS",
"LOGGING_ONLY_MODEL_INTERACTIONS", "LOGGING_BUFFER_SIZE",
"LOGGING_FLUSH_INTERVAL", "LOGGING_RETENTION_DAYS",
"USAGE_ENABLED", "ENFORCE_RETURNING_USAGE_DATA",
Expand Down
10 changes: 10 additions & 0 deletions config/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ type LogConfig struct {
// Default: false
LogAudioBodies bool `yaml:"log_audio_bodies" env:"LOGGING_LOG_AUDIO_BODIES"`

// LogRevisionBodies refines LogBodies for the request-revision chain:
// when both are enabled, every request rewriter that changed the body
// (for example GoModel Pro token compression) stores the full rewritten
// body alongside the original in the audit entry. Requires LogBodies.
// Disabling it keeps the revision metadata (rewriter name, sizes, tokens
// saved, change detail) but drops the rewritten body copy — roughly
// halving audit storage per compressed request.
// Default: true
LogRevisionBodies bool `yaml:"log_revision_bodies" env:"LOGGING_LOG_REVISION_BODIES"`

// LogHeaders enables logging of request/response headers
// Sensitive headers (Authorization, Cookie, etc.) are auto-redacted
// Default: true
Expand Down
11 changes: 11 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Storage is shared by audit logging, usage tracking, and future features like IAM
| --------------------------------- | ------------------------------------------ | ------- |
| `LOGGING_ENABLED` | Enable audit logging | `true` |
| `LOGGING_LOG_BODIES` | Log request/response bodies | `true` |
| `LOGGING_LOG_REVISION_BODIES` | Log rewritten bodies from request rewriters | `true` |
| `LOGGING_LOG_AUDIO_BODIES` | Log audio endpoint inputs/outputs | `false` |
| `LOGGING_LOG_HEADERS` | Log headers (sensitive ones auto-redacted) | `true` |
| `LOGGING_ONLY_MODEL_INTERACTIONS` | Only log AI model endpoints | `true` |
Expand All @@ -156,6 +157,16 @@ to `LOGGING_FLUSH_INTERVAL` seconds to appear through the stored-log API.
prompts.
</Warning>

<Note>
`LOGGING_LOG_REVISION_BODIES` refines `LOGGING_LOG_BODIES` for the ingress
request-rewrite chain — it has no effect unless body logging is enabled. With
both on, every rewriter that changed a request (for example GoModel Pro token
compression) stores the full rewritten body alongside the original client
body in the audit entry. Disabling it keeps the revision metadata (rewriter
name, byte sizes, tokens saved, change detail) but drops the extra body copy,
roughly halving audit storage per rewritten request.
</Note>

<Note>
`LOGGING_LOG_AUDIO_BODIES` refines `LOGGING_LOG_BODIES` for audio endpoints —
it has no effect unless body logging is enabled. With both on, `/v1/audio/speech`
Expand Down
14 changes: 14 additions & 0 deletions docs/openapi.json

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

146 changes: 146 additions & 0 deletions internal/admin/audit_projection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package admin

import "github.com/enterpilot/gomodel/internal/auditlog"

// Audit list projection.
//
// A list page ships up to 100 entries, and a full entry carries the original
// request body, the response body, per-attempt upstream error bodies, and one
// rewritten body per request revision — megabytes per row for agent traffic.
// The dashboard's collapsed row renders none of that: it needs scalar
// metadata, the attempt pips, and two derived signals (can this entry open
// the Interactions drawer, and has the request settled). The expanded row
// lazy-loads the full entry from /admin/audit/detail.
//
// slimAuditListEntry therefore strips the heavy payloads from a list entry
// and records what the client needs to compensate:
// - BodiesOmitted tells the dashboard to fetch the detail endpoint on
// expand (and that the entry is persisted, i.e. not in-flight).
// - ConversationPayload preserves the drawer-eligibility signal that the
// client would otherwise sniff from the removed bodies.
//
// The detail and conversation endpoints are not slimmed this way: detail is
// the designated source of full payloads, and the conversation thread is
// built client-side from request/response bodies (see slimConversationEntry
// for what it drops instead).
func slimAuditListEntry(resp *auditLogEntryResponse) {
d := resp.Data
if d == nil {
return
}

stripped := false
slim := *d

if slim.RequestBody != nil || slim.ResponseBody != nil {
resp.ConversationPayload = hasConversationPayload(slim.RequestBody, slim.ResponseBody)
}

if slim.RequestBody != nil {
slim.RequestBody = nil
stripped = true
}
if slim.ResponseBody != nil {
slim.ResponseBody = nil
stripped = true
}

if len(slim.Attempts) > 0 {
attempts := make([]auditlog.AttemptSnapshot, len(slim.Attempts))
copy(attempts, slim.Attempts)
for i := range attempts {
if attempts[i].ResponseBody != nil || attempts[i].ResponseHeaders != nil {
attempts[i].ResponseBody = nil
attempts[i].ResponseHeaders = nil
stripped = true
}
}
slim.Attempts = attempts
}

if len(slim.RequestRevisions) > 0 {
revisions := make([]auditlog.RequestRevisionSnapshot, len(slim.RequestRevisions))
copy(revisions, slim.RequestRevisions)
for i := range revisions {
if revisions[i].Body != nil {
revisions[i].Body = nil
stripped = true
}
}
slim.RequestRevisions = revisions
}

if !stripped {
return
}
resp.Data = &slim
resp.BodiesOmitted = true
}

// hasConversationPayload mirrors the dashboard's body sniff for entries whose
// path alone does not qualify them for the Interactions drawer (for example
// /v1/messages or provider passthrough): a request shaped like a conversation
// or a response shaped like model output.
func hasConversationPayload(requestBody, responseBody any) bool {
if req, ok := requestBody.(map[string]any); ok {
if _, ok := req["messages"].([]any); ok {
return true
}
if _, ok := req["input"]; ok {
return true
}
if _, ok := req["instructions"].(string); ok {
return true
}
if _, ok := req["previous_response_id"].(string); ok {
return true
}
}
if resp, ok := responseBody.(map[string]any); ok {
if _, ok := resp["choices"].([]any); ok {
return true
}
if looksLikeResponsesOutput(resp["output"]) {
return true
}
}
return false
}

// looksLikeResponsesOutput reports whether v is a Responses-API output array:
// a non-empty array of typed items.
func looksLikeResponsesOutput(v any) bool {
items, ok := v.([]any)
if !ok || len(items) == 0 {
return false
}
first, ok := items[0].(map[string]any)
if !ok {
return false
}
_, hasType := first["type"]
_, hasContent := first["content"]
return hasType || hasContent
}

// slimConversationEntry strips the fields the Interactions drawer never
// reads from a conversation-thread entry. The drawer builds its transcript
// from id, timestamp, request_body, response_body, and error_message;
// attempts, request revisions (each carrying a full rewritten body), and
// header maps only inflate the response — for agent traffic they roughly
// double it.
func slimConversationEntry(entry *auditlog.LogEntry) {
d := entry.Data
if d == nil {
return
}
if d.Attempts == nil && d.RequestRevisions == nil && d.RequestHeaders == nil && d.ResponseHeaders == nil {
return
}
slim := *d
slim.Attempts = nil
slim.RequestRevisions = nil
slim.RequestHeaders = nil
slim.ResponseHeaders = nil
entry.Data = &slim
}
Loading