Skip to content

fix: keep token_usage DB-resident in hybrid mode instead of offloading to object storage - #4732

Closed
impoiler wants to merge 1 commit into
06-25-docs_security-best-practices_docsfrom
06-26-fix_tokens_in_logs_list_api_when_object_storage_is_enabled
Closed

fix: keep token_usage DB-resident in hybrid mode instead of offloading to object storage#4732
impoiler wants to merge 1 commit into
06-25-docs_security-best-practices_docsfrom
06-26-fix_tokens_in_logs_list_api_when_object_storage_is_enabled

Conversation

@impoiler

Copy link
Copy Markdown
Contributor

Summary

In hybrid mode, token_usage was being offloaded to object storage and cleared from the database row, causing token counts to be unavailable in log list views and breaking token-based sorting without fetching every object from storage. This PR keeps token_usage DB-resident (like metadata) while still including it in the object-store snapshot for downstream consumers.

Changes

  • Removed token_usage from payloadFields so it is no longer cleared from the DB row during hybrid offload.
  • Removed token_usage and TokenUsageParsed from ClearPayload so the JSON column and parsed struct are preserved after upload.
  • ExtractPayload continues to write token_usage into the object-store snapshot so object consumers still receive the full payload.
  • Added a fallback in DeserializeFields to reconstruct TokenUsageParsed from the denormalized prompt_tokens, completion_tokens, and total_tokens columns for older rows where the JSON column was previously cleared.
  • Updated the object_storage_exclude_fields schema description to note that token_usage is always kept in the database and does not need to be listed explicitly.

Type of change

  • Bug fix

Affected areas

  • Core (Go)

How to test

go test ./framework/logstore/...

The new TestHybrid_TokenUsageStaysInDB test verifies that after a log is written and uploaded in hybrid mode, the token_usage JSON column and TotalTokens remain in the DB row and are returned correctly by SearchLogs. TestDeserializeFields_TokenUsageFromDenormalizedColumns verifies the fallback reconstruction path for older rows.

Breaking changes

  • No

Security considerations

No security implications. This change only affects which columns are retained in the database versus offloaded to object storage.

Checklist

  • I read docs/contributing/README.md and followed the guidelines
  • I added/updated tests where appropriate
  • I updated documentation where needed
  • I verified builds succeed (Go and UI)
  • I verified the CI pipeline passes locally if applicable

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • token_usage now remains available in the database after hybrid/offloaded log creation.
    • Search results correctly restore token usage details, including total token counts, for affected logs.
    • Legacy or hybrid records can now rebuild token usage from stored token counts when the JSON field is empty.
  • Documentation

    • Updated configuration guidance to clarify that token_usage is always kept in the database and does not need to be excluded.

Walkthrough

The PR keeps token_usage in the relational database during hybrid payload offload, updates payload-clearing behavior and schema wording, and adds deserialization fallback for rows with only denormalized token columns. Tests cover DB retention, payload extraction, and parsed token hydration.

Changes

Token usage retention and offload behavior

Layer / File(s) Summary
Preserve token usage during offload
framework/logstore/payload.go, framework/logstore/hybrid_test.go, framework/logstore/payload_test.go, transports/config.schema.json
payloadFields stops treating token_usage as an offloaded payload field, ClearPayload leaves the DB value intact, the schema text reflects that behavior, and tests cover extraction, DB retention, and hybrid search hydration.
Rebuild token usage from columns
framework/logstore/tables.go, framework/logstore/payload_test.go
DeserializeFields now repopulates TokenUsageParsed from denormalized token columns when the JSON column is empty, and a unit test covers the fallback.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

A rabbit hopped through logs at night,
With token crumbs tucked out of sight.
The DB kept them snug and true,
Then search found tokens back anew.
🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: keeping token_usage in the database during hybrid offload.
Description check ✅ Passed The description covers the summary, changes, type, affected area, testing, breaking changes, security, and checklist; only minor non-critical sections are missing.
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 06-26-fix_tokens_in_logs_list_api_when_object_storage_is_enabled

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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

impoiler commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@impoiler impoiler self-assigned this Jun 26, 2026
@impoiler
impoiler marked this pull request as ready for review June 26, 2026 13:33
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

Safe to merge; the fix correctly targets a well-scoped storage routing bug with matching tests for both new and legacy rows.

The core logic — removing token_usage from payloadFields/ClearPayload and adding a denormalized-column fallback — is correct and well-tested. Three minor issues prevent a perfect score: the ExtractPayload map capacity hint is off-by-one after the change; token_usage is always written into the snapshot even when empty (unlike the metadata guard pattern); and MergePayloadFromJSON restores token_usage from the snapshot on FindByID while metadata is intentionally left DB-authoritative, creating a design asymmetry that could produce stale data if token counts were ever amended after upload.

framework/logstore/payload.go — map capacity hint, unconditional empty token_usage in snapshot, and asymmetric snapshot-restore behaviour in MergePayloadFromJSON.

Important Files Changed

Filename Overview
framework/logstore/payload.go Removes token_usage from payloadFields and ClearPayload so it stays DB-resident; writes it into the object-store snapshot unconditionally (minor asymmetry with the metadata guard pattern). Map capacity hint is off-by-one after this change.
framework/logstore/tables.go Adds a fallback in DeserializeFields to reconstruct TokenUsageParsed from the denormalized columns when the JSON column is empty (legacy rows). Condition and reconstruction are correct.
framework/logstore/hybrid_test.go Adds TestHybrid_TokenUsageStaysInDB covering the full write-upload-search cycle; correctly checks both the raw DB column and the deserialized parsed struct.
framework/logstore/payload_test.go Updates TestExtractPayload_RoundTrip to assert len(payloadFields)+2 and token_usage DB-residency; adds TestDeserializeFields_TokenUsageFromDenormalizedColumns for the fallback path.
transports/config.schema.json Schema description for object_storage_exclude_fields updated to note token_usage is always DB-resident and does not need to be listed.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant Hybrid
    participant DB
    participant ObjStore

    App->>Hybrid: CreateIfNotExists(log)
    Hybrid->>Hybrid: SerializeFields() sets TokenUsage JSON
    Hybrid->>Hybrid: ExtractPayload() snapshot includes token_usage
    Hybrid->>Hybrid: ClearPayload() clears large fields (NOT token_usage)
    Hybrid->>DB: INSERT (token_usage JSON stays in row)
    Hybrid->>ObjStore: Upload snapshot (token_usage also in snapshot)

    App->>Hybrid: SearchLogs()
    Hybrid->>DB: SELECT rows (token_usage JSON present)
    DB-->>Hybrid: rows with TokenUsage
    Hybrid->>Hybrid: DeserializeFields() parses TokenUsageParsed
    Note over Hybrid: Fallback: if TokenUsage empty rebuild from denormalized cols
    Hybrid-->>App: Logs with TotalTokens populated

    App->>Hybrid: FindByID(id)
    Hybrid->>DB: SELECT row
    Hybrid->>ObjStore: Download snapshot
    Hybrid->>Hybrid: MergePayloadFromJSON() restores large fields + token_usage
    Hybrid-->>App: Full log with all fields
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App
    participant Hybrid
    participant DB
    participant ObjStore

    App->>Hybrid: CreateIfNotExists(log)
    Hybrid->>Hybrid: SerializeFields() sets TokenUsage JSON
    Hybrid->>Hybrid: ExtractPayload() snapshot includes token_usage
    Hybrid->>Hybrid: ClearPayload() clears large fields (NOT token_usage)
    Hybrid->>DB: INSERT (token_usage JSON stays in row)
    Hybrid->>ObjStore: Upload snapshot (token_usage also in snapshot)

    App->>Hybrid: SearchLogs()
    Hybrid->>DB: SELECT rows (token_usage JSON present)
    DB-->>Hybrid: rows with TokenUsage
    Hybrid->>Hybrid: DeserializeFields() parses TokenUsageParsed
    Note over Hybrid: Fallback: if TokenUsage empty rebuild from denormalized cols
    Hybrid-->>App: Logs with TotalTokens populated

    App->>Hybrid: FindByID(id)
    Hybrid->>DB: SELECT row
    Hybrid->>ObjStore: Download snapshot
    Hybrid->>Hybrid: MergePayloadFromJSON() restores large fields + token_usage
    Hybrid-->>App: Full log with all fields
Loading

Comments Outside Diff (2)

  1. framework/logstore/payload.go, line 59 (link)

    P2 ExtractPayload pre-allocates the map with len(payloadFields)+1, which was sized for the payload fields plus one conditional metadata entry. With token_usage now always inserted unconditionally (regardless of whether it is empty), the map grows to len(payloadFields)+2 entries whenever metadata is also present, triggering an unnecessary rehash on every upload in the common case.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. framework/logstore/payload.go, line 270-272 (link)

    P2 token_usage restored from snapshot on FindByID, unlike metadata

    MergePayloadFromJSON silently overwrites the DB's token_usage JSON with the object-store snapshot value. The code comment for metadata (line 291-293) explicitly calls out that the DB row is authoritative and snapshot copies are not restored; the same rationale applies to token_usage now that it is DB-resident. For new rows both values are identical, so there is no current data loss — but if token_usage were ever amended in-DB after upload (e.g. a cost-correction workflow), FindByID would silently return the stale snapshot value. Worth aligning the treatment with metadata or adding a parallel comment explaining why the asymmetry is intentional here.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "fix: tokens in logs list API when object..." | Re-trigger Greptile

Comment thread framework/logstore/payload.go

@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.

🧹 Nitpick comments (2)
framework/logstore/payload.go (1)

59-59: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Bump the map capacity hint to match actual entries.

token_usage is no longer in payloadFields but is always written, and metadata may also be added — so the map can hold up to len(payloadFields)+2 entries while the hint is +1. This is a harmless minor under-allocation (the test already expects +2), but aligning the hint avoids an extra growth.

♻️ Capacity hint tweak
-	m := make(map[string]string, len(payloadFields)+1)
+	m := make(map[string]string, len(payloadFields)+2)
🤖 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 `@framework/logstore/payload.go` at line 59, The map capacity hint in payload
construction is too low for the actual number of entries being inserted. Update
the map allocation in the payload builder around the map initialization so the
hint matches the maximum written keys, including token_usage and the optional
metadata field; use the payloadFields-based constructor logic to reflect up to
len(payloadFields)+2 entries instead of len(payloadFields)+1.
framework/logstore/tables.go (1)

730-739: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Fallback drops CachedReadTokens.

SerializeFields also denormalizes CachedReadTokens (from PromptTokensDetails.CachedReadTokens), but this reconstruction only restores prompt/completion/total. For older offloaded rows that had cached-read tokens, the rebuilt TokenUsageParsed will report them as zero. If cache analytics from list/search responses matter for those legacy rows, consider repopulating PromptTokensDetails from l.CachedReadTokens when non-zero. Otherwise this is acceptable as a best-effort hydration.

🤖 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 `@framework/logstore/tables.go` around lines 730 - 739, The fallback hydration
in SerializeFields rebuilds TokenUsageParsed from PromptTokens,
CompletionTokens, and TotalTokens only, so it loses CachedReadTokens for legacy
offloaded rows. Update the reconstruction logic in tables.go to also repopulate
PromptTokensDetails with CachedReadTokens when l.CachedReadTokens is non-zero,
alongside the existing BifrostLLMUsage fields, so list/search responses preserve
the denormalized cache-read data.
🤖 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.

Nitpick comments:
In `@framework/logstore/payload.go`:
- Line 59: The map capacity hint in payload construction is too low for the
actual number of entries being inserted. Update the map allocation in the
payload builder around the map initialization so the hint matches the maximum
written keys, including token_usage and the optional metadata field; use the
payloadFields-based constructor logic to reflect up to len(payloadFields)+2
entries instead of len(payloadFields)+1.

In `@framework/logstore/tables.go`:
- Around line 730-739: The fallback hydration in SerializeFields rebuilds
TokenUsageParsed from PromptTokens, CompletionTokens, and TotalTokens only, so
it loses CachedReadTokens for legacy offloaded rows. Update the reconstruction
logic in tables.go to also repopulate PromptTokensDetails with CachedReadTokens
when l.CachedReadTokens is non-zero, alongside the existing BifrostLLMUsage
fields, so list/search responses preserve the denormalized cache-read data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 77e6f32b-6d9b-40d8-bb28-fe8a4751bd2d

📥 Commits

Reviewing files that changed from the base of the PR and between 66edb16 and e4e938f.

📒 Files selected for processing (5)
  • framework/logstore/hybrid_test.go
  • framework/logstore/payload.go
  • framework/logstore/payload_test.go
  • framework/logstore/tables.go
  • transports/config.schema.json

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.

1 participant