Skip to content

Add Analytics Chats and preserve usage history - #1016

Draft
GraciousGazelles wants to merge 19 commits into
mainfrom
codex/w14939-analytics-foundation-20261105
Draft

GraciousGazelles wants to merge 19 commits into
mainfrom
codex/w14939-analytics-foundation-20261105

Conversation

@GraciousGazelles

@GraciousGazelles GraciousGazelles commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

The current /usage menu lacks the operator's Analytics Chats view. Add a bounded, account-scoped Chats view for supported workspace and consumer plans, with page-local ranking, explicit partial-history and unavailable-data handling, and labelled backend credit estimates. Read the existing local usage ledger for per-thread root, subagent and fork detail; do not add a writer or PostgreSQL ingestion. Preserve old usage database files and SQLite sidecars at startup; leave current-database opening and migrations unchanged.

The 30-day indexed, page-local scope and reduced Analytics controls were accepted in w14939#112827. This draft is a source component for the existing w14939 owner, not a replacement integration programme or a qualified binary: reliable single-writer recording (PR #1015), restoration of the installed durable mailbox, the existing native-wait work (PR #1000), historical-source reconciliation, final composition and owner acceptance remain open. Existing PR #995 and rollback paths remain intact.

Validation so far:

  • Current head d5218e4fe5d10145a802647c30fa075cd974c6b4 preserves older usage database files and sidecars and labels local usage as potentially incomplete as well as overcounted. Predecessor 9eed19ce1b80709837bb55da02cf2575ffe664b4 passed hosted full state and TUI library runs (42032502585 and 42032502606), including preservation, lineage and the seven requested Chats/usage selectors; exact-head hosted full state and TUI library runs passed (42033428873 and 42033428877); Linux full Clippy, Rust CI gate and Linux/macOS Bazel Clippy passed; cargo-deny required gate failed and other checks remain in progress. Combined-source qualification remains open.
  • Predecessor a293d38acb70087261831fcf73850d0e72f09072: hosted full TUI library command passed, including all seven requested Chats and usage selectors (run 42021009106); usage-picker tests 35/35 and maintained full argument-comment lint also passed. Protected Clippy failed on the now-corrected clone and cargo-deny failed; the exact hosted cargo-deny log was unavailable. Local cargo-deny 0.19.0 reported the same five Rust advisories on protected main and predecessor.
  • Exact a8427f2d2e3c8262553a0cadd007a5dbe7948442: nine Chats seam tests, provider-auth library checks, MCP safety and docs lanes passed; the successor adds reviewed snapshots only.
  • Exact 8c31447111d9e81669177af5b49fa0565bc86ea6: mailbox orchestration, blocking/capacity waits, MCP device login, weekly pacing and state-migration lanes passed.
  • Validation-lab's separate argument-comment lane failed in its old Dylint driver before linting; the existing maintained lint action passed without changing protected checks. Preliminary Linux preview builds are pending and are not a delivery.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces account-scoped Analytics Chats to the TUI /usage menu, allowing users to view chat usage and estimates. It adds an AnalyticsSession client for authentication and plan discovery, implements backend queries for task and thread usage, integrates these into the TUI with pagination and local lineage details, and updates the relevant documentation and tests. The review feedback focuses on improving efficiency and backward compatibility, suggesting the replacement of is_none_or with map_or for older Rust toolchains, checking collection limits early to abort iterations, and optimizing checks to avoid unnecessary string allocations and cloning.

Comment on lines +98 to +101
if current.is_none_or(|auth| {
auth.get_account_id() != self.auth.get_account_id()
|| auth.get_chatgpt_user_id() != self.auth.get_chatgpt_user_id()
}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using is_none_or requires Rust 1.82.0 or newer. To ensure better backward compatibility with older Rust toolchains (MSRV), consider using map_or(true, ...) instead.

Suggested change
if current.is_none_or(|auth| {
auth.get_account_id() != self.auth.get_account_id()
|| auth.get_chatgpt_user_id() != self.auth.get_chatgpt_user_id()
}) {
if current.map_or(true, |auth| {
auth.get_account_id() != self.auth.get_account_id()
|| auth.get_chatgpt_user_id() != self.auth.get_chatgpt_user_id()
}) {

Comment on lines +81 to +90
let mut ids = HashSet::new();
if threads.is_empty()
|| threads.len() > 100
|| threads.iter().any(|thread| {
std::iter::once(&thread.thread_id)
.chain(&thread.descendant_thread_ids)
.any(|id| id.trim().is_empty() || id.len() > 512 || !ids.insert(id.as_str()))
})
|| ids.len() > 1_000
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Evaluating ids.len() > 1_000 only after the entire any iteration finishes can lead to excessive memory allocation and CPU usage if an input contains a very large number of descendant thread IDs. Checking the limit inside the closure allows the iteration to abort early.

        let mut ids = HashSet::new();
        if threads.is_empty()
            || threads.len() > 100
            || threads.iter().any(|thread| {
                std::iter::once(&thread.thread_id)
                    .chain(&thread.descendant_thread_ids)
                    .any(|id| {
                        id.trim().is_empty()
                            || id.len() > 512
                            || !ids.insert(id.as_str())
                            || ids.len() > 1_000
                    })
            })
        {

Comment on lines +76 to +81
let requested: std::collections::HashSet<_> = thread_ids.iter().copied().collect();
if thread_ids.is_empty() || thread_ids.len() > 100 || requested.len() != thread_ids.len() {
return Err(RequestError::from(anyhow!(
"expected 1–100 distinct thread IDs"
)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Checking the bounds of thread_ids after collecting them into a HashSet is inefficient. Performing the length and emptiness checks first avoids unnecessary memory allocation when the input is invalid.

Suggested change
let requested: std::collections::HashSet<_> = thread_ids.iter().copied().collect();
if thread_ids.is_empty() || thread_ids.len() > 100 || requested.len() != thread_ids.len() {
return Err(RequestError::from(anyhow!(
"expected 1–100 distinct thread IDs"
)));
}
if thread_ids.is_empty() || thread_ids.len() > 100 {
return Err(RequestError::from(anyhow!(
"expected 1–100 distinct thread IDs"
)));
}
let requested: std::collections::HashSet<_> = thread_ids.iter().copied().collect();
if requested.len() != thread_ids.len() {
return Err(RequestError::from(anyhow!(
"expected 1–100 distinct thread IDs"
)));
}

Comment on lines +247 to +266
let mut seen = HashSet::new();
let mut batches = Vec::new();
let mut batch = Vec::new();
let mut count = 0;
for query in queries {
let ids = std::iter::once(&query.thread_id).chain(&query.descendant_thread_ids);
if ids.clone().any(|id| !seen.insert(id.clone())) {
return Err("Task descendant groups overlap.".to_string());
}
let size = 1 + query.descendant_thread_ids.len();
if count + size > 1_000 || batch.len() == 100 {
batches.push(std::mem::take(&mut batch));
count = 0;
}
count += size;
batch.push(query);
}
if !batch.is_empty() {
batches.push(batch);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Cloning all thread IDs during the overlap check is inefficient. By splitting this into a two-pass approach, we can perform a read-only overlap check using HashSet<&str> to completely avoid string allocations, and then perform the batching in a separate pass.

    let mut seen = HashSet::new();
    for query in &queries {
        let ids = std::iter::once(query.thread_id.as_str())
            .chain(query.descendant_thread_ids.iter().map(|id| id.as_str()));
        if ids.clone().any(|id| !seen.insert(id)) {
            return Err("Task descendant groups overlap.".to_string());
        }
    }

    let mut batches = Vec::new();
    let mut batch = Vec::new();
    let mut count = 0;
    for query in queries {
        let size = 1 + query.descendant_thread_ids.len();
        if count + size > 1_000 || batch.len() == 100 {
            batches.push(std::mem::take(&mut batch));
            count = 0;
        }
        count += size;
        batch.push(query);
    }
    if !batch.is_empty() {
        batches.push(batch);
    }

Comment on lines +317 to +321
let partial_usage = rows.iter().any(|row| {
row.usage
.as_ref()
.is_none_or(|usage| usage.data_status != TaskUsageStatus::Available)
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using is_none_or requires Rust 1.82.0 or newer. To ensure better backward compatibility with older Rust toolchains (MSRV), consider using map_or(true, ...) instead.

Suggested change
let partial_usage = rows.iter().any(|row| {
row.usage
.as_ref()
.is_none_or(|usage| usage.data_status != TaskUsageStatus::Available)
});
let partial_usage = rows.iter().any(|row| {
row.usage
.as_ref()
.map_or(true, |usage| usage.data_status != TaskUsageStatus::Available)
});

@GraciousGazelles GraciousGazelles changed the title Add account-scoped Analytics Chats Add Analytics Chats and preserve usage history Nov 5, 2026
let legacy_usage_path = codex_home.join(format!("{unversioned_usage_name}{suffix}"));
assert_eq!(
tokio::fs::try_exists(&legacy_usage_path)
tokio::fs::read(&legacy_usage_path)
let old_usage_path = codex_home.join(format!("usage_0.sqlite{suffix}"));
assert_eq!(
tokio::fs::try_exists(&old_usage_path)
tokio::fs::read(&old_usage_path)

This branch has not been deployed

No deployments
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.

2 participants