Skip to content

fix(auditlog): keep request revisions on streamed entries - #595

Merged
SantiagoDePolonia merged 2 commits into
mainfrom
fix/stream-entry-drops-request-revisions
Jul 26, 2026
Merged

fix(auditlog): keep request revisions on streamed entries#595
SantiagoDePolonia merged 2 commits into
mainfrom
fix/stream-entry-drops-request-revisions

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

A streamed request is persisted from the CreateStreamEntry copy — the base entry is never written — so an ingress rewrite chain recorded by EnrichEntryWithRequestRevision was lost the moment the request streamed. CreateStreamEntry builds LogData with a field whitelist, and RequestRevisions was not on it.

The visible effect: the "Rewritten" audit pane was empty for every successful streamed request, while the same request on the non-streamed error path kept its snapshot.

Evidence

Successful chat completions in a local database, cross-referenced against whether a request rewriter actually fired:

stream revision rewriter fired n
0 no no 5
0 yes no 4
0 yes yes 2
1 no yes 157
1 no no 73

The bug row — fired but no revision — exists only for streamed requests. Non-streaming never lost one, because CreateStreamEntry has only two callers and both are streaming paths.

Changes

  • CreateStreamEntry copies RequestRevisions (through slices.Clone, so the copy owns its slice) and RequestBodyTooBigToHandle.
  • A reflection-based drift guard walks LogData and fails when any request-side field does not survive the whitelist copy. Response-side fields are exempted by name — that list is the only thing a future field has to be added to, instead of silently vanishing the way RequestRevisions did.

Operational note

Streamed requests will now each persist a revision, and a revision carries a full copy of the rewritten body (gated by LogBodies, capped at MaxBodyCapture = 1 MiB). Audit storage will grow faster than before: from storing ~1% of streamed rewrites to 100% of them.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Streaming audit log entries now correctly preserve request revision details, including rewrite information, byte counts, and saved tokens.
    • Additional request metadata—such as oversized-body indicators and ingress rewrite chains—is retained accurately, without sharing backing storage where applicable.
  • Tests
    • Added regression tests to ensure request-side log fields are copied reliably (including nil vs empty behavior) and that no request-side fields are unintentionally dropped.

CreateStreamEntry rebuilds LogData with a field whitelist, and the stream
observer completes and persists that copy — the base entry is never
written. RequestRevisions was missing from the whitelist, so the ingress
rewrite chain recorded by EnrichEntryWithRequestRevision was discarded on
every streamed request. The audit UI's "Rewritten" pane therefore never
appeared for successful streams, while surviving on the non-streamed error
path.

Measured on a live gateway: of 72 requests where a rewriter reported token
savings into the usage table, 0 retained a revision snapshot; all 6
non-streamed failures in the same window kept theirs.

RequestBodyTooBigToHandle was dropped the same way — also a request-side
fact known before the stream opens.

Response-side fields (ResponseBody, ErrorMessage, ErrorCode,
ResponseBodyTooBigToHandle) stay omitted on purpose: the observer fills
them once the stream closes.

Adds a reflection-based drift guard that walks LogData and fails when any
request-side field does not survive the copy, since the whitelist will keep
attracting this bug otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 26, 2026 20:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

CreateStreamEntry now copies additional request-side LogData fields, including RequestRevisions, using a cloned slice. New tests verify revision details, boolean metadata, slice independence, nil preservation, and all request-side fields.

Changes

Streamed audit entry field preservation

Layer / File(s) Summary
Expand streamed request-data copying
internal/auditlog/stream_wrapper.go
CreateStreamEntry copies the expanded request-side LogData whitelist and clones RequestRevisions to avoid shared slice storage.
Validate request-field preservation
internal/auditlog/stream_entry_request_fields_test.go
Tests verify revision details, request metadata, slice independence, nil preservation, and preservation of every settable request-side field.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: copilot

Poem

A rabbit hops through audit streams,
Copying fields from request-side dreams.
Revisions clone, no slices shared,
Every datum’s safely paired.
Hop hooray, the tests now sing!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: preserving request revisions on streamed audit entries.
Description check ✅ Passed The description matches the template with required sections and clearly explains the bug, evidence, changes, and impact.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stream-entry-drops-request-revisions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auditlog/stream_entry_request_fields_test.go`:
- Around line 25-128: Convert TestCreateStreamEntryPreservesRequestRevisions
into table-driven cases covering absent and populated RequestRevisions and
request metadata, asserting each case’s expected copied values and ownership
behavior. Retain TestCreateStreamEntryCopiesEveryRequestSideField as the
reflection-based drift guard, and keep the cases focused on CreateStreamEntry
behavior.
- Around line 61-65: Update the regression test around the RequestRevisions copy
to mutate an existing element in base.Data.RequestRevisions after the stream
entry is created, then assert the corresponding streamed element remains
unchanged. Keep the append assertion if useful, but ensure the test specifically
detects shared backing arrays rather than only reallocation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3554e603-5721-440d-9192-a6bae2569f48

📥 Commits

Reviewing files that changed from the base of the PR and between f46b955 and 141984b.

📒 Files selected for processing (2)
  • internal/auditlog/stream_entry_request_fields_test.go
  • internal/auditlog/stream_wrapper.go

Comment on lines +25 to +128
func TestCreateStreamEntryPreservesRequestRevisions(t *testing.T) {
base := &LogEntry{
ID: "entry-1",
Path: "/v1/chat/completions",
Data: &LogData{
RequestRevisions: []RequestRevisionSnapshot{{
Seq: 1,
Rewriter: "pro-token-compression",
BytesBefore: 65209,
BytesAfter: 64418,
TokensSaved: 189,
Detail: map[string]any{"chars_removed": 757},
}},
RequestBodyTooBigToHandle: true,
},
}

streamEntry := CreateStreamEntry(base)
if streamEntry == nil || streamEntry.Data == nil {
t.Fatal("expected a stream entry with data")
}

got := streamEntry.Data.RequestRevisions
if len(got) != 1 {
t.Fatalf("RequestRevisions dropped: got %d revisions, want 1", len(got))
}
if got[0].Rewriter != "pro-token-compression" || got[0].TokensSaved != 189 {
t.Fatalf("revision not copied faithfully: %+v", got[0])
}
if got[0].BytesBefore != 65209 || got[0].BytesAfter != 64418 {
t.Fatalf("revision byte counts not copied: %+v", got[0])
}
if !streamEntry.Data.RequestBodyTooBigToHandle {
t.Error("RequestBodyTooBigToHandle dropped")
}

// The copy must own its slice, so later appends to the base entry cannot
// reach into the entry the observer is writing.
base.Data.RequestRevisions = append(base.Data.RequestRevisions, RequestRevisionSnapshot{Seq: 2})
if len(streamEntry.Data.RequestRevisions) != 1 {
t.Error("stream entry shares its revision backing array with the base entry")
}
}

// CreateStreamEntry builds LogData with a field whitelist, so any request-side
// field added to LogData later is silently dropped until someone remembers to
// extend that literal. This walks LogData by reflection and fails when a
// request-side field does not survive, which is how RequestRevisions went
// missing in the first place.
func TestCreateStreamEntryCopiesEveryRequestSideField(t *testing.T) {
populated := &LogData{}
v := reflect.ValueOf(populated).Elem()
typ := v.Type()

for i := range typ.NumField() {
field := typ.Field(i)
if !v.Field(i).CanSet() {
continue
}
if !setRecognizableValue(v.Field(i)) {
t.Fatalf("test needs a sample value for LogData.%s (%s)", field.Name, field.Type)
}
}

streamEntry := CreateStreamEntry(&LogEntry{ID: "entry-1", Data: populated})
if streamEntry == nil || streamEntry.Data == nil {
t.Fatal("expected a stream entry with data")
}

copied := reflect.ValueOf(streamEntry.Data).Elem()
for i := range typ.NumField() {
name := typ.Field(i).Name
if responseSideLogDataFields[name] {
continue
}
if copied.Field(i).IsZero() {
t.Errorf("LogData.%s is a request-side field but CreateStreamEntry dropped it", name)
}
}
}

// setRecognizableValue fills one field with a non-zero value so a dropped
// field shows up as the zero value on the other side of the copy.
func setRecognizableValue(field reflect.Value) bool {
switch field.Kind() {
case reflect.String:
field.SetString("x")
case reflect.Bool:
field.SetBool(true)
case reflect.Map:
m := reflect.MakeMap(field.Type())
m.SetMapIndex(reflect.ValueOf("k"), reflect.ValueOf("v"))
field.Set(m)
case reflect.Slice:
field.Set(reflect.MakeSlice(field.Type(), 1, 1))
case reflect.Pointer:
field.Set(reflect.New(field.Type().Elem()))
case reflect.Interface:
field.Set(reflect.ValueOf(map[string]any{"k": "v"}))
default:
return false
}
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use table-driven cases for the new behavior.

Cover at least absent and populated request revisions/metadata as table cases, while retaining the reflection drift guard. As per coding guidelines, “Add or update table-driven tests for behavior changes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auditlog/stream_entry_request_fields_test.go` around lines 25 - 128,
Convert TestCreateStreamEntryPreservesRequestRevisions into table-driven cases
covering absent and populated RequestRevisions and request metadata, asserting
each case’s expected copied values and ownership behavior. Retain
TestCreateStreamEntryCopiesEveryRequestSideField as the reflection-based drift
guard, and keep the cases focused on CreateStreamEntry behavior.

Source: Coding guidelines

Comment thread internal/auditlog/stream_entry_request_fields_test.go Outdated
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The change appears safe to merge with no actionable correctness or security issues identified.

The streamed audit copy retains the missing request metadata, and ownership and field-preservation tests cover the corrected behavior without exposing a reachable regression.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the Go test for TestCreateStreamEntry in internal/auditlog; the run completed with exit code 0 and both tests passed.

View all artifacts

T-Rex Ran code and verified through T-Rex

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Base audit entry] --> B[Request rewrite enrichment]
  B --> C[CreateStreamEntry]
  C --> D[Cloned request revisions]
  D --> E[Stream observer]
  E --> F[Persisted streamed audit entry]
Loading

Reviews (1): Last reviewed commit: "fix(auditlog): keep request revisions on..." | Re-trigger Greptile

The append-based check could not fail: the source literal has no spare
capacity, so append reallocates and leaves the copy untouched whether or
not the two share a backing array. Verified by swapping the clone for a
shallow assignment — the test still passed. Writing through an existing
element does distinguish them, and now fails against that same shallow
assignment.

Also covers the absent-revisions case, which is what most streamed
requests hit: it must stay nil rather than becoming an empty slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 26, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Thanks — the aliasing point was correct and it was a real hole in the test, not just a style nit.

I confirmed it before fixing: swapping copyRequestRevisions for a plain shallow assignment left TestCreateStreamEntryPreservesRequestRevisions passing. The source literal has no spare capacity, so append reallocates regardless of sharing. Now it writes through an existing element, and that same shallow assignment fails it:

--- FAIL: TestCreateStreamEntryPreservesRequestRevisions
    stream entry shares its revision backing array with the base entry

I also added the absent-revisions case, which is the common path — most streamed requests carry no rewriter, and the field must stay nil rather than becoming an empty slice.

On the table-driven suggestion: skipping it. The two cases here assert different things (one checks field-by-field fidelity plus slice ownership, the other checks a nil stays nil), so folding them into a table would mean a case struct of mostly-unused fields and a body of conditionals — less legible than two named tests, not more. The reflection drift guard already provides the exhaustive per-field coverage a table would be reaching for.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/auditlog/stream_entry_request_fields_test.go (1)

30-56: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the sequence and detail fields too.

The test initializes Seq and Detail but never verifies them, so a copy implementation that drops either field would still pass.

Proposed assertion adjustment
-	if got[0].Rewriter != "pro-token-compression" || got[0].TokensSaved != 189 {
+	if got[0].Seq != 1 ||
+		got[0].Rewriter != "pro-token-compression" ||
+		got[0].TokensSaved != 189 ||
+		got[0].Detail["chars_removed"] != 757 {
 		t.Fatalf("revision not copied faithfully: %+v", got[0])
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auditlog/stream_entry_request_fields_test.go` around lines 30 - 56,
Extend the assertions in the streamEntry RequestRevisions test to verify that
the copied revision preserves both Seq and Detail, alongside the existing
Rewriter, TokensSaved, and byte-count checks. Compare Seq to 1 and Detail to the
initialized chars_removed value, while keeping the current failure reporting
style.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/auditlog/stream_entry_request_fields_test.go`:
- Around line 30-56: Extend the assertions in the streamEntry RequestRevisions
test to verify that the copied revision preserves both Seq and Detail, alongside
the existing Rewriter, TokensSaved, and byte-count checks. Compare Seq to 1 and
Detail to the initialized chars_removed value, while keeping the current failure
reporting style.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c7ca9295-d56d-427c-bfda-c08541ce58cc

📥 Commits

Reviewing files that changed from the base of the PR and between 141984b and 16cf4e8.

📒 Files selected for processing (1)
  • internal/auditlog/stream_entry_request_fields_test.go

@SantiagoDePolonia
SantiagoDePolonia merged commit d5b8324 into main Jul 26, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants