Skip to content

Commit e36a3c2

Browse files
feat: add firstActivity to session JSON output (#1222)
* feat: add firstActivity to session JSON output Add firstActivity field alongside lastActivity in session JSON output for both ccusage and opencode. Use full RFC 3339 timestamps for both fields in JSON, while table display truncates to date-only. * fix(opencode): preserve session id in session summaries The firstActivity change switched OpenCode session grouping to use SessionAccumulator, which already writes session_id from the latest entry. The old summarize_by_key migration loop still ran afterward and replaced that value with row.date.take(), leaving session JSON with a null sessionId. Remove that stale migration step and add a regression test that verifies the session id and first/last activity bounds. Update session JSON documentation examples to show firstActivity and RFC 3339 lastActivity values so the docs match the new public output shape. --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: ryoppippi <1560508+ryoppippi@users.noreply.github.com>
1 parent 22e5944 commit e36a3c2

19 files changed

Lines changed: 149 additions & 40 deletions

docs/guide/json-output.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ ccusage daily --project my-frontend-app --json
157157
"cacheReadTokens": 1024,
158158
"totalTokens": 356894,
159159
"costUSD": 156.4,
160-
"lastActivity": "2026-05-15"
160+
"firstActivity": "2026-05-15T09:30:00.000Z",
161+
"lastActivity": "2026-05-15T17:45:30.000Z"
161162
}
162163
],
163164
"summary": {
@@ -230,7 +231,8 @@ ccusage daily --project my-frontend-app --json
230231
#### Session Reports
231232

232233
- `session`: Session identifier
233-
- `lastActivity`: Date of last activity in the session
234+
- `firstActivity`: RFC 3339 timestamp of first activity in the session
235+
- `lastActivity`: RFC 3339 timestamp of last activity in the session
234236

235237
#### Blocks Reports
236238

docs/guide/openclaw/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ ccusage openclaw monthly --since 2026-01-01 --until 2026-03-31
154154

155155
## Session View
156156

157-
This view shows usage grouped by individual OpenClaw sessions. Session IDs come from the JSONL filename stem (the part before `.jsonl`, ignoring `.deleted.<ts>` or `.reset.<ts>` suffixes), and metadata records the last activity date and provider that produced the most recent activity.
157+
This view shows usage grouped by individual OpenClaw sessions. Session IDs come from the JSONL filename stem (the part before `.jsonl`, ignoring `.deleted.<ts>` or `.reset.<ts>` suffixes), and JSON output records activity timestamps and provider metadata for the most recent activity.
158158

159159
```bash
160160
ccusage openclaw session

docs/guide/pi/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ Returns structured data including full paths:
302302
"cacheCreationTokens": 1234,
303303
"cacheReadTokens": 9876,
304304
"totalCost": 0.12,
305-
"lastActivity": "2026-05-16",
305+
"firstActivity": "2026-05-15T09:30:00.000Z",
306+
"lastActivity": "2026-05-16T17:45:30.000Z",
306307
"modelsUsed": ["claude-opus-4-1-20250805"],
307308
"modelBreakdowns": [...]
308309
}

docs/guide/session-reports.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ ccusage session --json
187187
"cacheReadTokens": 1024,
188188
"totalTokens": 356894,
189189
"totalCost": 156.4,
190-
"lastActivity": "2026-05-16",
190+
"firstActivity": "2026-05-15T09:30:00.000Z",
191+
"lastActivity": "2026-05-16T17:45:30.000Z",
191192
"modelsUsed": ["opus-4-1", "sonnet-4-5"],
192193
"modelBreakdowns": [
193194
{

rust/crates/ccusage/src/adapter/all/loader.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ fn load_session_capable_summary_agent_rows(
374374
let mut entries = load_entries(shared, None, Some(pricing))?;
375375
let detected = !entries.is_empty();
376376
let summaries = if kind == AgentReportKind::Session {
377-
let mut summaries = summarize_entry_sessions(&entries, shared.timezone.as_deref())?;
377+
let mut summaries = summarize_entry_sessions(&entries)?;
378378
filter_session_summaries(&mut summaries, shared);
379379
summaries
380380
} else {
@@ -391,7 +391,7 @@ fn load_claude_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result<AgentR
391391
if kind == AgentReportKind::Session {
392392
let entries = claude::load_entries(shared, None)?;
393393
let detected = !entries.is_empty();
394-
let mut summaries = summarize_entry_sessions(&entries, shared.timezone.as_deref())?;
394+
let mut summaries = summarize_entry_sessions(&entries)?;
395395
filter_session_summaries(&mut summaries, shared);
396396
return Ok(AgentRows {
397397
rows: summary_rows("claude", summaries),
@@ -487,10 +487,7 @@ fn load_qwen_rows(kind: AgentReportKind, shared: &SharedArgs) -> Result<AgentRow
487487
})
488488
}
489489

490-
fn summarize_entry_sessions(
491-
entries: &[LoadedEntry],
492-
timezone: Option<&str>,
493-
) -> Result<Vec<UsageSummary>> {
490+
fn summarize_entry_sessions(entries: &[LoadedEntry]) -> Result<Vec<UsageSummary>> {
494491
let mut groups = BTreeMap::<(String, String), SessionAccumulator>::new();
495492
for entry in entries {
496493
groups
@@ -500,7 +497,7 @@ fn summarize_entry_sessions(
500497
}
501498
groups
502499
.into_values()
503-
.map(|group| group.into_summary(timezone))
500+
.map(|group| group.into_summary())
504501
.collect()
505502
}
506503

@@ -654,6 +651,7 @@ mod tests {
654651
session_id: None,
655652
project_path: None,
656653
last_activity: None,
654+
first_activity: None,
657655
input_tokens,
658656
output_tokens: 0,
659657
cache_creation_tokens: 0,

rust/crates/ccusage/src/adapter/all/report.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ fn all_rows_as_usage_summaries(rows: &[AllRow]) -> Vec<UsageSummary> {
191191
session_id: None,
192192
project_path: None,
193193
last_activity: None,
194+
first_activity: None,
194195
input_tokens: row.input_tokens,
195196
output_tokens: row.output_tokens,
196197
cache_creation_tokens: row.cache_creation_tokens,

rust/crates/ccusage/src/adapter/claude/daily.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ impl DailyAccumulator {
492492
session_id: None,
493493
project_path: None,
494494
last_activity: None,
495+
first_activity: None,
495496
input_tokens: self.counts.input_tokens,
496497
output_tokens: self.counts.output_tokens,
497498
cache_creation_tokens: self.counts.cache_creation_tokens,

rust/crates/ccusage/src/adapter/openclaw/report.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub(crate) fn summarize_entries(
4949
}
5050
groups
5151
.into_values()
52-
.map(|group| group.into_summary(None))
52+
.map(|group| group.into_summary())
5353
.collect()
5454
}
5555
AgentReportKind::Weekly => {

rust/crates/ccusage/src/adapter/opencode/report.rs

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use serde_json::{json, Value};
33
use crate::{
44
cli::{AgentReportKind, SortOrder, WeekDay},
55
sort_summaries, summarize_by_key, summarize_summaries_by_bucket, totals_json, BucketKind,
6-
LoadedEntry, Result,
6+
LoadedEntry, Result, SessionAccumulator,
77
};
88

99
pub(crate) fn report_json(
@@ -56,6 +56,12 @@ pub(crate) fn agent_summary_json(
5656
.as_ref()
5757
.map_or(Value::Null, |value| json!(value)),
5858
);
59+
obj.insert(
60+
"firstActivity".to_string(),
61+
row.first_activity
62+
.as_ref()
63+
.map_or(Value::Null, |value| json!(value)),
64+
);
5965
obj.insert(
6066
"projectPath".to_string(),
6167
row.project_path
@@ -101,17 +107,24 @@ pub(crate) fn summarize_entries(
101107
WeekDay::Sunday,
102108
))
103109
}
104-
AgentReportKind::Session => summarize_by_key(
105-
entries,
106-
|entry| entry.session_id.to_string(),
107-
|session_id| (session_id.to_string(), None),
108-
)
109-
.map(|mut rows| {
110-
for row in &mut rows {
111-
row.session_id = row.date.take();
110+
AgentReportKind::Session => {
111+
let mut grouped: Vec<SessionAccumulator> = Vec::new();
112+
let mut group_indexes = std::collections::HashMap::new();
113+
for entry in entries {
114+
let key = &entry.session_id;
115+
let index = *group_indexes.entry(key.clone()).or_insert_with(|| {
116+
let index = grouped.len();
117+
grouped.push(SessionAccumulator::default());
118+
index
119+
});
120+
grouped[index].add_entry(entry);
121+
}
122+
let mut rows = Vec::with_capacity(grouped.len());
123+
for group in grouped {
124+
rows.push(group.into_summary()?);
112125
}
113-
rows
114-
}),
126+
Ok(rows)
127+
}
115128
}
116129
}
117130

@@ -153,8 +166,13 @@ pub(crate) fn summary_period(row: &crate::UsageSummary) -> &str {
153166

154167
#[cfg(test)]
155168
mod tests {
169+
use std::sync::Arc;
170+
156171
use super::*;
157-
use crate::{cli::AgentReportKind, ModelBreakdown, UsageSummary};
172+
use crate::{
173+
cli::AgentReportKind, format_rfc3339_millis, LoadedEntry, ModelBreakdown, TimestampMs,
174+
TokenUsageRaw, UsageEntry, UsageMessage, UsageSummary,
175+
};
158176

159177
#[test]
160178
fn snapshots_agent_summary_json_period_keys_and_session_metadata() {
@@ -180,14 +198,36 @@ mod tests {
180198
}));
181199
}
182200

201+
#[test]
202+
fn summarize_session_entries_preserves_session_id_and_activity_bounds() {
203+
let entries = vec![
204+
loaded_entry("session-a", 1_767_316_800_000, 100),
205+
loaded_entry("session-a", 1_767_402_000_000, 20),
206+
];
207+
208+
let rows = summarize_entries(&entries, AgentReportKind::Session).unwrap();
209+
210+
assert_eq!(rows.len(), 1);
211+
assert_eq!(rows[0].session_id.as_deref(), Some("session-a"));
212+
assert_eq!(
213+
rows[0].first_activity.as_deref(),
214+
Some("2026-01-02T01:20:00.000Z")
215+
);
216+
assert_eq!(
217+
rows[0].last_activity.as_deref(),
218+
Some("2026-01-03T01:00:00.000Z")
219+
);
220+
}
221+
183222
fn snapshot_row() -> UsageSummary {
184223
UsageSummary {
185224
date: Some("2026-01-02".to_string()),
186225
month: Some("2026-01".to_string()),
187226
week: Some("2025-12-29".to_string()),
188227
session_id: Some("session-a".to_string()),
189228
project_path: Some("/workspace/api".to_string()),
190-
last_activity: Some("2026-01-02".to_string()),
229+
last_activity: Some("2026-01-02T12:34:56.000Z".to_string()),
230+
first_activity: Some("2026-01-01T10:30:00.000Z".to_string()),
191231
input_tokens: 100,
192232
output_tokens: 50,
193233
cache_creation_tokens: 10,
@@ -214,4 +254,43 @@ mod tests {
214254
versions: Some(vec!["1.0.0".to_string()]),
215255
}
216256
}
257+
258+
fn loaded_entry(session_id: &str, timestamp_millis: i64, input_tokens: u64) -> LoadedEntry {
259+
let timestamp = TimestampMs::from_millis(timestamp_millis);
260+
LoadedEntry {
261+
data: UsageEntry {
262+
session_id: Some(session_id.to_string()),
263+
timestamp: format_rfc3339_millis(timestamp),
264+
version: None,
265+
message: UsageMessage {
266+
usage: TokenUsageRaw {
267+
input_tokens,
268+
output_tokens: 0,
269+
cache_creation_input_tokens: 0,
270+
cache_read_input_tokens: 0,
271+
cache_creation: None,
272+
speed: None,
273+
},
274+
model: Some("gpt-5.2-codex".to_string()),
275+
id: Some(format!("msg-{timestamp_millis}")),
276+
},
277+
cost_usd: None,
278+
request_id: None,
279+
is_api_error_message: None,
280+
is_sidechain: None,
281+
},
282+
timestamp,
283+
date: "2026-01-02".to_string(),
284+
project: Arc::from("opencode"),
285+
session_id: Arc::from(session_id),
286+
project_path: Arc::from("/workspace/api"),
287+
cost: 0.0,
288+
extra_total_tokens: 0,
289+
credits: None,
290+
message_count: Some(1),
291+
model: Some("gpt-5.2-codex".to_string()),
292+
usage_limit_reset_time: None,
293+
missing_pricing_model: None,
294+
}
295+
}
217296
}

rust/crates/ccusage/src/adapter/opencode/snapshots/ccusage__adapter__opencode__report__tests__snapshots_agent_summary_json_period_keys_and_session_metadata.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
22
source: crates/ccusage/src/adapter/opencode/report.rs
3-
assertion_line: 173
43
expression: "serde_json::json!({\n \"daily\": agent_summary_json(&daily, AgentReportKind::Daily, false),\n \"weekly\": agent_summary_json(&weekly, AgentReportKind::Weekly, false),\n \"monthly\": agent_summary_json(&monthly, AgentReportKind::Monthly, false),\n \"session\": agent_summary_json(&session, AgentReportKind::Session, true),\n \"dailyReport\":\n report_from_rows(std::slice::from_ref(&daily), AgentReportKind::Daily),\n \"sessionReport\": report_from_rows(&[session], AgentReportKind::Session),\n})"
54
---
65
{
@@ -66,8 +65,9 @@ expression: "serde_json::json!({\n \"daily\": agent_summary_json(&daily, Agen
6665
"cacheCreationTokens": 10,
6766
"cacheReadTokens": 5,
6867
"credits": 1.5,
68+
"firstActivity": "2026-01-01T10:30:00.000Z",
6969
"inputTokens": 100,
70-
"lastActivity": "2026-01-02",
70+
"lastActivity": "2026-01-02T12:34:56.000Z",
7171
"messageCount": 3,
7272
"modelsUsed": [
7373
"gpt-5.2-codex",

0 commit comments

Comments
 (0)