Skip to content

feat(server): add streaming CSV export endpoint for bucket events - #722

Open
TimeToBuildBob wants to merge 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/csv-export-streaming
Open

TimeToBuildBob wants to merge 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/csv-export-streaming

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Summary

Fixes the large bucket CSV export silently failing on Android WebView (and slow networks) by moving CSV generation server-side with immediate header delivery.

Root cause: The existing CSV export in aw-webui fetches all events as JSON into JS memory, converts with PapaParse, then creates a Blob. On Android WebView with 500k+ events this causes OOM or a connection that appears hung until the full response is ready.

Fix: New endpoint GET /api/0/buckets/{id}/export/csv that:

  • Sends 200 OK with Content-Type: text/csv and Content-Disposition headers before serialization begins (same streaming pattern as fix(export): send HTTP headers before serializing large exports #721 for JSON export)
  • Generates RFC-4180 CSV server-side: columns id,timestamp,duration + all data-map keys from the first event
  • Accepts the same start, end, limit query params as the JSON events endpoint
  • Returns a proper JSON 404 for missing buckets (before any streaming begins, so errors are well-formed)

The route path is /export/csv (parallel to /export for JSON) to avoid any Rocket route collision with /<bucket_id>/events/<event_id>.

Changes

  • aw-server/src/endpoints/util.rs — BucketEventsCsvRocket struct + Responder impl; events_to_csv() + csv_escape() helpers
  • aw-server/src/endpoints/bucket.rs — bucket_events_get_csv route at /<bucket_id>/export/csv
  • aw-server/src/endpoints/mod.rs — register new route
  • aw-server/tests/api.rs — csv_export_returns_csv_with_correct_headers_and_missing_bucket_errors test

Related

GET /api/0/buckets/{id}/export/csv streams events as CSV, sending HTTP
headers before serialization so large buckets don't look like a hung
connection on Android WebView or slow networks.

Mirrors the existing streaming JSON export (BucketsExportRocket / ActivityWatch#721):
OS pipe + background thread serializes into a tempfile, then copies to
the client. The route is at /export/csv (parallel to /export for JSON)
to avoid any Rocket route collision with the single-event endpoint.

RFC-4180 CSV: id, timestamp, duration (fractional seconds), then all
data-map keys from the first event. Fields containing commas, quotes, or
newlines are double-quoted and internal quotes are doubled.

Test: csv_export_returns_csv_with_correct_headers_and_missing_bucket_errors
Git-Session-Id: cdc6
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because a large export still materializes every matching event in memory before CSV serialization.

Findings

  1. P1 Exports Still Exhaust Memory ▶
  2. P1 Duration Precision Is Lost ▶
  3. P1 Failures Return Successful Exports ▶
  4. P1 Security CSV Formulas Remain Executable ▶
  5. P1 Export Blocks Datastore Worker ▶
  6. P1 Final Flush Errors Disappear ▶

Summary

Adds a streaming CSV export endpoint for bucket events with RFC-4180 escaping, spreadsheet-formula neutralization, nanosecond duration precision, time-range filtering, and JSON errors before response commitment.

  • Registers GET /api/0/buckets/<bucket_id>/export/csv.
  • Generates CSV in a background thread through a temporary staging file.
  • Explicitly flushes buffered CSV output before rewinding and copying the file.
  • Adds datastore and endpoint tests for headers, formatting, precision, formula neutralization, missing buckets, and flush failures.

Diagram

sequenceDiagram
    participant Client
    participant Endpoint
    participant DatastoreWorker
    participant ExportThread
    participant TempFile
    Client->>Endpoint: "GET /buckets/{id}/export/csv"
    Endpoint->>DatastoreWorker: Preflight bucket and event query
    DatastoreWorker-->>Endpoint: Success or JSON error
    Endpoint-->>Client: 200 CSV headers and streamed body
    Endpoint->>ExportThread: Spawn export
    ExportThread->>DatastoreWorker: Fetch matching events
    DatastoreWorker-->>ExportThread: Event vector
    ExportThread->>TempFile: Serialize and explicitly flush CSV
    TempFile-->>Client: Copy staged CSV through pipe
Loading

Reviews (4) · Last reviewed commit: "fix(export): surface CSV staging flush e..."

Comment thread aw-server/src/endpoints/util.rs Outdated
Comment thread aw-server/src/endpoints/util.rs Outdated
Comment thread aw-server/src/endpoints/util.rs Outdated
Comment thread aw-server/src/endpoints/util.rs Outdated
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.00000% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.74%. Comparing base (656f3c9) to head (09f2391).
⚠️ Report is 135 commits behind head on master.

Files with missing lines Patch % Lines
aw-datastore/src/export.rs 78.30% 23 Missing ⚠️
aw-server/src/endpoints/bucket.rs 33.33% 12 Missing ⚠️
aw-server/src/endpoints/util.rs 76.59% 11 Missing ⚠️
aw-datastore/src/datastore.rs 22.22% 7 Missing ⚠️
aw-server/tests/api.rs 97.72% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #722      +/-   ##
==========================================
+ Coverage   70.81%   79.74%   +8.92%     
==========================================
  Files          51       75      +24     
  Lines        2916     8628    +5712     
==========================================
+ Hits         2065     6880    +4815     
- Misses        851     1748     +897     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Address Greptile P1s on ActivityWatch#722:
- Write CSV from SQL row-by-row on the datastore worker (no full Vec + String)
- Format duration from nanoseconds so 0.0015s is not truncated to 0.001
- Neutralize spreadsheet formula prefixes (=, +, -, @)
- Preflight LIMIT 1 before 200 so worker/SQL failures still return JSON

Git-Session-Id: 79e64905-13b9-5822-b880-3863140ba2d7
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-datastore/src/worker.rs Outdated
The ExportEventsCsv worker command blocked the single shared datastore
worker for the entire duration of the export — heartbeats and all other
requests queued until serialization finished.

Fix: remove ExportEventsCsv from the worker. The background thread now
calls get_events() (worker holds the DB lock only for the SQL read), then
serializes CSV via write_csv_from_events() off-worker. The worker is free
for other requests during the write.

write_csv_from_events() is the Vec<Event>-based counterpart to the
connection-based write_events_csv(); both produce identical RFC-4180 output
with ns-precision durations and formula neutralization.

Git-Session-Id: bb28
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw-server/src/endpoints/util.rs Outdated
BufWriter::drop swallows a final flush failure, so a full staging
filesystem still rewound and copied a truncated CSV under 200 OK.
Keep the writer in a local, flush explicitly, and flush inside
write_csv_from_events / write_events_csv so the error propagates.

Git-Session-Id: 01a0ce79
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Greptile re-review of 09f2391 completed with no new findings. CI is green (Build + Lint + coverage). Self-merge not eligible here (cross-repo / no merge permission). Waiting for human review or merge.

@TimeToBuildBob

TimeToBuildBob commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

Adds a streaming CSV export endpoint for a single bucket's events. The new route GET /api/0/buckets/<bucket_id>/export/csv parses start/end/limit query params, validates the bucket and a LIMIT 1 probe before committing headers, then streams CSV from a background thread via a pipe. The datastore gains write_events_csv and write_csv_from_events helpers, CSV escaping with spreadsheet formula neutralization, and tests for duration precision, escaping, flush errors, and row counts.

Not safe to merge — 3 P1 open

Confidence 2/5

3 findings · ❌ 3 P1 · 🔒 1 security

❌ P1 high — aw-server/src/endpoints/util.rs:177

The CSV export fetches all events into memory via datastore.get_events before writing any CSV, so the claimed streaming benefit is lost for large buckets. In spawn_csv_export_stream, let events = match datastore.get_events(...) materializes the entire Vec<Event> in RAM, then write_csv_from_events serializes that slice. For the 500k+ event buckets that motivated this PR, this can OOM the server process just like the client-side OOM it was meant to fix. The datastore-level write_events_csv streams row-by-row from SQL, but the HTTP path never uses it; it uses the worker's get_events which returns a full Vec. The observable consequence is a memory spike proportional to bucket size, potentially crashing the server on large exports.

How this was verified: Checked aw-server/src/endpoints/util.rs lines 177-193: get_events returns Vec<Event>, then write_csv_from_events(&events, ...) serializes the whole slice. The datastore-level write_events_csv (aw-datastore/src/export.rs:237) streams from SQL but is not called from the HTTP path.

❌ P1 high — aw-datastore/src/export.rs:221

The CSV column set is derived from the first event's data keys, but later events may have different keys. write_csv_from_events and write_events_csv both collect data_keys from the first event (or first valid event) and then emit empty cells for missing keys in subsequent rows. This is a schema mismatch: if the first event has keys {a,b} and a later event has {a,c}, the CSV has columns id,timestamp,duration,a,b and the later event's c value is silently dropped while its b cell is empty. The webui's client-side CSV used PapaParse on the full JSON array, which would have included all keys across all events. The observable consequence is data loss in the exported CSV for buckets with heterogeneous event data, which is common in ActivityWatch (e.g. app/title keys vary).

How this was verified: Checked aw-datastore/src/export.rs lines 221-224 and 306-311: data_keys is set once from the first event and reused for all rows. event_field_value (line 158) returns empty string for missing keys, so extra keys are dropped.

❌ P1 high · 🔒 security — aw-datastore/src/export.rs:134

The formula neutralization in neutralize_formula only checks the first character, but spreadsheet formula injection can also occur with leading whitespace before the formula character. For example, a value like " =1+1" (space then equals) is not neutralized because the first char is a space, not one of the listed starters. Excel and LibreOffice trim leading whitespace before evaluating formulas, so a cell containing " =1+1" will still execute as a formula when opened. The current code only handles tab and carriage return as whitespace starters, not space. The observable consequence is that a crafted event data value can bypass the CSV injection protection and execute a formula in a user's spreadsheet, which is a security issue for exported data.

match s.trim_start().chars().next() {

How this was verified: Checked neutralize_formula (lines 132-137) and csv_escape (lines 140-147). A string starting with a space is not matched by the first-char match, and csv_escape only quotes if it contains comma, quote, newline, or CR. So " =1+1" passes through unquoted and unneutralized.

1 advisory finding (summary-only, not scored)

These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.

⚠️ P2 medium — aw-server/src/endpoints/util.rs:240

The CSV export endpoint's pre-flight check in BucketEventsCsvRocket::new calls datastore.get_events(bucket_id, start, end, Some(1)) to force the same SQL path and catch errors before headers commit. However, this check runs a full query with LIMIT 1, which is not the same as the actual export query when limit is None. The actual export in spawn_csv_export_stream calls get_events with the original limit (which may be None), so a bucket with a corrupt row that appears after the first row will not be caught by the pre-flight check, but will be skipped during the actual export (with a warning) — that is fine. More importantly, the pre-flight check does not validate that the bucket has any events; it only checks that the query succeeds. If the bucket exists but the datastore worker is down, get_bucket would fail first, so that is covered. The real issue is that the pre-flight get_events with Some(1) can return Ok even when the full query would fail due to a SQL error that only manifests with a larger scan (e.g., a corrupt index or a row that fails to parse after the first). But parse errors are skipped, not fatal, so the full export would also succeed with skipped rows. The pre-flight check is redundant and adds an extra round-trip to the worker, but it does not cause a correctness bug. This is a minor performance concern, not a defect.

How this was verified: Traced the pre-flight and actual export paths. The pre-flight uses Some(1) while the actual uses the original limit. No distinct failure mode found beyond the extra query.

Files changed (8) — the diff as I read it
  • aw-datastore/src/datastore.rs — Makes prefer_endtime_index pub(crate) and adds write_events_csv delegating to export::write_events_csv.
  • aw-datastore/src/export.rs — Adds CSV escaping, formula neutralization, duration formatting, write_csv_from_events, write_events_csv, and tests.
  • aw-datastore/src/lib.rs — Re-exports write_csv_from_events as a public API.
  • aw-datastore/src/worker.rs — Adds doc comment for insert_events (no functional change).
  • aw-server/src/endpoints/bucket.rs — Adds bucket_events_get_csv route parsing start/end/limit and constructing BucketEventsCsvRocket.
  • aw-server/src/endpoints/mod.rs — Registers the new CSV export route.
  • aw-server/src/endpoints/util.rs — Adds BucketEventsCsvRocket responder and spawn_csv_export_stream background thread.
  • aw-server/tests/api.rs — Adds integration test for CSV export headers, content, and missing bucket 404.

Reviewed 09f23911d466 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 146s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

// Fetch events via the worker. The worker holds the DB lock only for
// this SQL read, then is immediately free for heartbeats and other
// requests while CSV serialization runs here, off-worker.
let events = match datastore.get_events(&bucket_id, start, end, limit) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

❌ P1 — The CSV export fetches all events into memory via datastore.get_events before writing any CSV, so the claimed streaming benefit is lost for large buckets. In spawn_csv_export_stream, let events = match datastore.get_events(...) materializes the entire Vec in RAM, then write_csv_from_events serializes that slice. For the 500k+ event buckets that motivated this PR, this can OOM the server process just like the client-side OOM it was meant to fix. The datastore-level write_events_csv streams row-by-row from SQL, but the HTTP path never uses it; it uses the worker's get_events which returns a full Vec. The observable consequence is a memory spike proportional to bucket size, potentially crashing the server on large exports.

events: &[aw_models::Event],
mut writer: impl Write,
) -> Result<(), DatastoreError> {
let data_keys: Vec<String> = events

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

❌ P1 — The CSV column set is derived from the first event's data keys, but later events may have different keys. write_csv_from_events and write_events_csv both collect data_keys from the first event (or first valid event) and then emit empty cells for missing keys in subsequent rows. This is a schema mismatch: if the first event has keys {a,b} and a later event has {a,c}, the CSV has columns id,timestamp,duration,a,b and the later event's c value is silently dropped while its b cell is empty. The webui's client-side CSV used PapaParse on the full JSON array, which would have included all keys across all events. The observable consequence is data loss in the exported CSV for buckets with heterogeneous event data, which is common in ActivityWatch (e.g. app/title keys vary).

/// Prefix spreadsheet-formula starters so Excel/Sheets will not execute them.
fn neutralize_formula(s: &str) -> String {
match s.chars().next() {
Some('=' | '+' | '-' | '@' | '\t' | '\r') => format!("'{s}"),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

❌ P1 — The formula neutralization in neutralize_formula only checks the first character, but spreadsheet formula injection can also occur with leading whitespace before the formula character. For example, a value like " =1+1" (space then equals) is not neutralized because the first char is a space, not one of the listed starters. Excel and LibreOffice trim leading whitespace before evaluating formulas, so a cell containing " =1+1" will still execute as a formula when opened. The current code only handles tab and carriage return as whitespace starters, not space. The observable consequence is that a crafted event data value can bypass the CSV injection protection and execute a formula in a user's spreadsheet, which is a security issue for exported data.

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