checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-03-19 11:27:55 -04:00
parent e367f68cf3
commit 2555796a0f
6 changed files with 498 additions and 22 deletions

File diff suppressed because one or more lines are too long

260
nodes/implement/diff.patch Normal file
View file

@ -0,0 +1,260 @@
diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs
index 103977da..daf6e3dd 100644
--- a/lib/crates/fabro-cli/src/main.rs
+++ b/lib/crates/fabro-cli/src/main.rs
@@ -426,7 +426,7 @@ fn send_telemetry_event(
});
let track = telemetry.build_track(event_name, properties);
- fabro_util::telemetry::sender::send(track);
+ fabro_util::telemetry::sender::emit(&[track]);
debug!(
event = event_name,
subcommand = command_name,
@@ -908,7 +908,7 @@ async fn main_inner() -> (String, Result<()>) {
}
},
Command::SendAnalytics { path } => {
- let result = fabro_util::telemetry::sender::send_to_segment(&path).await;
+ let result = fabro_util::telemetry::sender::upload(&path).await;
let _ = std::fs::remove_file(&path);
result?;
}
diff --git a/lib/crates/fabro-util/src/telemetry/sender.rs b/lib/crates/fabro-util/src/telemetry/sender.rs
index bf89ce79..640fdbd5 100644
--- a/lib/crates/fabro-util/src/telemetry/sender.rs
+++ b/lib/crates/fabro-util/src/telemetry/sender.rs
@@ -2,52 +2,101 @@ use std::path::Path;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
+use uuid::Uuid;
use super::event::Track;
-const SEGMENT_API_URL: &str = "https://api.segment.io/v1/track";
+const SEGMENT_API_URL: &str = "https://api.segment.io/v1/batch";
const SEGMENT_WRITE_KEY: Option<&str> = option_env!("SEGMENT_WRITE_KEY");
-/// Serializes the track event to a temp file and spawns a detached subprocess
-/// (`fabro __send_analytics <path>`) to deliver it. This ensures the event is
-/// sent even if the parent CLI process exits immediately.
+/// Serializes the track events as JSONL to a temp file and spawns a detached
+/// subprocess (`fabro __send_analytics <path>`) to deliver them. This ensures
+/// the events are sent even if the parent CLI process exits immediately.
///
-/// No-ops if the SEGMENT_WRITE_KEY was not set at compile time.
-pub fn send(track: Track) {
+/// No-ops if the SEGMENT_WRITE_KEY was not set at compile time or `tracks` is empty.
+pub fn emit(tracks: &[Track]) {
if SEGMENT_WRITE_KEY.is_none() {
- tracing::debug!("telemetry: no SEGMENT_WRITE_KEY, skipping send");
+ tracing::debug!("telemetry: no SEGMENT_WRITE_KEY, skipping emit");
return;
}
- spawn_sender(track);
+ if tracks.is_empty() {
+ return;
+ }
+
+ spawn_sender(tracks);
}
-fn spawn_sender(track: Track) {
- let json = match serde_json::to_vec(&track) {
- Ok(j) => j,
- Err(_) => return,
- };
+fn spawn_sender(tracks: &[Track]) {
+ let lines: Vec<String> = tracks
+ .iter()
+ .filter_map(|t| serde_json::to_string(t).ok())
+ .collect();
- let filename = format!("fabro-event-{}.json", track.message_id);
- super::spawn::spawn_fabro_subcommand("__send_analytics", &filename, &json);
+ if lines.is_empty() {
+ return;
+ }
+
+ let jsonl = lines.join("\n");
+ let filename = format!("fabro-events-{}.jsonl", Uuid::new_v4());
+ super::spawn::spawn_fabro_subcommand("__send_analytics", &filename, jsonl.as_bytes());
}
-/// Reads a serialized track event from `path` and sends it to Segment.
+/// Parse JSONL content into a Segment batch payload.
+///
+/// Each non-empty line is parsed as JSON, has `"type": "track"` injected,
+/// and is collected into a `{"batch": [...]}` wrapper.
+/// Returns `None` if no valid events are found.
+fn build_segment_batch(content: &str) -> Option<serde_json::Value> {
+ let mut batch = Vec::new();
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ match serde_json::from_str::<serde_json::Value>(line) {
+ Ok(mut obj) => {
+ if let Some(map) = obj.as_object_mut() {
+ map.insert(
+ "type".to_string(),
+ serde_json::Value::String("track".to_string()),
+ );
+ }
+ batch.push(obj);
+ }
+ Err(err) => {
+ tracing::warn!(%err, "skipping malformed JSONL line");
+ }
+ }
+ }
+
+ if batch.is_empty() {
+ return None;
+ }
+
+ Some(serde_json::json!({ "batch": batch }))
+}
+
+/// Reads a JSONL file of serialized track events from `path` and sends them
+/// to Segment as a batch.
/// Called by the `__send_analytics` subcommand.
/// No-ops if `SEGMENT_WRITE_KEY` was not set at compile time.
-pub async fn send_to_segment(path: &Path) -> anyhow::Result<()> {
+pub async fn upload(path: &Path) -> anyhow::Result<()> {
let write_key = SEGMENT_WRITE_KEY
.ok_or_else(|| anyhow::anyhow!("SEGMENT_WRITE_KEY not set at compile time"))?;
- let json = std::fs::read(path)?;
- let track: Track = serde_json::from_slice(&json)?;
+ let content = std::fs::read_to_string(path)?;
+ let payload = match build_segment_batch(&content) {
+ Some(p) => p,
+ None => return Ok(()),
+ };
let auth = STANDARD.encode(format!("{write_key}:"));
let resp = reqwest::Client::new()
.post(SEGMENT_API_URL)
.header("Authorization", format!("Basic {auth}"))
- .json(&track)
+ .json(&payload)
.send()
.await?;
@@ -64,10 +113,82 @@ mod tests {
use crate::telemetry::event::User;
use serde_json::json;
+ // -- Step 1: build_segment_batch tests --
+
+ #[test]
+ fn build_segment_batch_empty_content() {
+ assert!(build_segment_batch("").is_none());
+ }
+
+ #[test]
+ fn build_segment_batch_single_event() {
+ let line = r#"{"anonymousId":"abc","event":"Test","properties":{},"messageId":"m1"}"#;
+ let result = build_segment_batch(line).unwrap();
+
+ let batch = result["batch"].as_array().unwrap();
+ assert_eq!(batch.len(), 1);
+ assert_eq!(batch[0]["type"], "track");
+ assert_eq!(batch[0]["event"], "Test");
+ assert_eq!(batch[0]["anonymousId"], "abc");
+ }
+
+ #[test]
+ fn build_segment_batch_multiple_events() {
+ let content = concat!(
+ r#"{"anonymousId":"a","event":"E1","properties":{},"messageId":"m1"}"#,
+ "\n",
+ r#"{"anonymousId":"b","event":"E2","properties":{},"messageId":"m2"}"#,
+ );
+ let result = build_segment_batch(content).unwrap();
+
+ let batch = result["batch"].as_array().unwrap();
+ assert_eq!(batch.len(), 2);
+ assert_eq!(batch[0]["type"], "track");
+ assert_eq!(batch[0]["event"], "E1");
+ assert_eq!(batch[1]["type"], "track");
+ assert_eq!(batch[1]["event"], "E2");
+ }
+
#[test]
- fn send_noops_without_write_key() {
+ fn build_segment_batch_skips_malformed_lines() {
+ let content = concat!(
+ r#"{"anonymousId":"a","event":"Good","properties":{},"messageId":"m1"}"#,
+ "\n",
+ "this is not json",
+ );
+ let result = build_segment_batch(content).unwrap();
+
+ let batch = result["batch"].as_array().unwrap();
+ assert_eq!(batch.len(), 1);
+ assert_eq!(batch[0]["event"], "Good");
+ }
+
+ #[test]
+ fn build_segment_batch_all_malformed() {
+ let content = "not json\nalso not json\n";
+ assert!(build_segment_batch(content).is_none());
+ }
+
+ #[test]
+ fn build_segment_batch_skips_blank_lines() {
+ let content = concat!(
+ "\n",
+ r#"{"anonymousId":"a","event":"E1","properties":{},"messageId":"m1"}"#,
+ "\n",
+ "\n",
+ );
+ let result = build_segment_batch(content).unwrap();
+
+ let batch = result["batch"].as_array().unwrap();
+ assert_eq!(batch.len(), 1);
+ }
+
+ // -- Step 2: emit() tests --
+
+ #[test]
+ fn emit_noops_without_write_key() {
// SEGMENT_WRITE_KEY is not set at compile time in tests,
- // so send() should return immediately without spawning.
+ // so emit() should return immediately without spawning.
let track = Track {
user: User::AnonymousId {
anonymous_id: "test".to_string(),
@@ -80,7 +201,24 @@ mod tests {
};
// This should not panic or require a tokio runtime
- // because it returns before reaching tokio::spawn
- send(track);
+ // because it returns before reaching spawn
+ emit(&[track]);
+ }
+
+ #[test]
+ fn emit_noops_with_empty_tracks() {
+ emit(&[]);
+ }
+
+ // -- Step 3: upload() tests --
+
+ #[test]
+ fn upload_noops_without_write_key() {
+ // SEGMENT_WRITE_KEY is not set at compile time in tests, so this should error.
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(upload(Path::new("/nonexistent")));
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(err_msg.contains("SEGMENT_WRITE_KEY not set"));
}
}

View file

@ -0,0 +1,160 @@
Goal: # Plan: JSONL analytics event file format
## Context
Currently each CLI invocation writes a single `Track` event as a standalone JSON file (`~/.fabro/tmp/fabro-event-{uuid}.json`) and spawns a detached subprocess to send it. We want to switch to JSONL format (one JSON event per line) so a single file can contain multiple events. Filenames keep a UUID for uniqueness. This enables callers to batch multiple events into one file/subprocess.
The panic sender (`__send_panic`) is unaffected — it stays single-JSON-per-file.
## Changes
### 1. `lib/crates/fabro-util/src/telemetry/sender.rs` — rewrite
**Writer — rename `send()` to `emit()`, accept multiple events:**
- `pub fn emit(tracks: &[Track])` (was `pub fn send(track: Track)`)
- Early return if `SEGMENT_WRITE_KEY` is `None` or `tracks` is empty
- Generate a UUID for the filename: `fabro-events-{uuid}.jsonl`
- Serialize each `Track` as a compact JSON line (`serde_json::to_string`), join with `\n`
- Pass the bytes to `spawn_fabro_subcommand("__send_analytics", &filename, &json)` as before
No file locking needed — each invocation writes its own uniquely-named file.
**Reader — rename `send_to_segment()` to `upload()`:**
- Read file contents as string
- Parse each non-empty line as `serde_json::Value`, inject `"type": "track"`, collect into batch array
- Skip malformed lines with `tracing::warn!`
- If no valid events, return `Ok(())`
- POST to `https://api.segment.io/v1/batch` with payload `{"batch": [...]}`
- Keep Basic auth the same
Extract a pure `fn build_segment_batch(content: &str) -> Option<Value>` for testability.
**Constants:**
- Change `SEGMENT_API_URL` from `.../v1/track` to `.../v1/batch`
### 2. `lib/crates/fabro-cli/src/main.rs`
**`send_telemetry_event()` (~line 428):** Change call from `sender::send(track)` to `sender::emit(&[track])`.
**`SendAnalytics` handler (~line 910):** Change call from `sender::send_to_segment(&path)` to `sender::upload(&path)`.
### 3. `lib/crates/fabro-util/src/telemetry/spawn.rs` — no changes
`spawn_fabro_subcommand` is generic (takes raw bytes). It continues to work for both JSONL analytics files and single-JSON panic files.
### 4. No changes to these files
- `event.rs` — `Track` struct unchanged
- `panic.rs` — stays single-JSON-per-file
- `mod.rs`, `anonymous_id.rs`, `context.rs`, `git.rs`, `sanitize.rs` — unchanged
## Implementation order (red/green TDD)
Write each test first (red), then implement just enough to make it pass (green).
### Step 1: `build_segment_batch` — pure function, no I/O
1. **Red:** Write test `build_segment_batch_empty_content` — empty string returns `None`
2. **Green:** Add `fn build_segment_batch(content: &str) -> Option<Value>` stub returning `None`
3. **Red:** Write test `build_segment_batch_single_event` — one JSONL line produces `{"batch": [{"type": "track", ...}]}`
4. **Green:** Implement line parsing, `"type": "track"` injection, batch wrapping
5. **Red:** Write test `build_segment_batch_multiple_events` — two lines produce batch of 2
6. **Green:** Should already pass
7. **Red:** Write test `build_segment_batch_skips_malformed_lines` — one good + one bad line produces batch of 1
8. **Green:** Add `continue` on parse error
### Step 2: `emit()` — writer side
9. **Red:** Update existing `send_noops_without_write_key` to use `emit(&[track])` signature
10. **Green:** Rename `send` to `emit`, change signature to `&[Track]`, serialize as JSONL (one JSON line per track, joined with `\n`), generate `fabro-events-{uuid}.jsonl` filename
### Step 3: `upload()` — reader side
11. **Red:** Write test `upload_noops_without_write_key` — same pattern as existing `send_panic_noops_without_dsn`
12. **Green:** Rename `send_to_segment` to `upload`, change internals to read file as string, call `build_segment_batch`, POST to `/v1/batch`
### Step 4: Wire up call sites in `main.rs`
13. Update `send_telemetry_event()` to call `sender::emit(&[track])`
14. Update `SendAnalytics` handler to call `sender::upload(&path)`
### Step 5: Final checks
```bash
cargo fmt --check --all
cargo clippy --workspace -- -D warnings
cargo test -p fabro-util
cargo test --workspace
```
## Completed stages
- **toolchain**: success
- Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1`
- Stdout:
```
cargo 1.94.0 (85eff7c80 2026-01-15)
```
- Stderr: (empty)
- **preflight_compile**: success
- Script: `cargo check -q --workspace 2>&1`
- Stdout: (empty)
- Stderr: (empty)
- **preflight_lint**: success
- Script: `cargo clippy -q --workspace -- -D warnings 2>&1`
- Stdout: (empty)
- Stderr: (empty)
- **implement**: success
- Model: claude-opus-4-6, 38.6k tokens in / 4.6k out
- Files: /home/daytona/workspace/lib/crates/fabro-cli/src/main.rs, /home/daytona/workspace/lib/crates/fabro-util/src/telemetry/sender.rs
# Simplify: Code Review and Cleanup
Review all changed files for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
## Phase 2: Launch Three Review Agents in Parallel
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
### Agent 1: Code Reuse Review
For each change:
1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.
### Agent 2: Code Quality Review
Review the same changes for hacky patterns:
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
Note: This is a greenfield app, so be aggressive in optimizing quality.
### Agent 3: Efficiency Review
Review the same changes for efficiency:
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
2. Missed concurrency: independent operations run sequentially when they could run in parallel
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
5. Memory: unbounded data structures, missing cleanup, event listener leaks
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one
## Phase 3: Fix Issues
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
When done, briefly summarize what was fixed (or confirm the code was already clean).

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "anthropic",
"model": "claude-opus-4-6"
}

View file

@ -0,0 +1,17 @@
## Summary
**One issue found and fixed:**
**Bug in `build_segment_batch`: non-object JSON values silently bypassed `"type": "track"` injection** (Code Quality finding). The original code parsed each line as `serde_json::Value`, then used `if let Some(map) = obj.as_object_mut()` to inject the type field — but `batch.push(obj)` was *outside* the `if let`, meaning non-object JSON (numbers, strings, arrays) would be added to the batch without the required `"type"` field.
**Fix:** Changed the parse target from `serde_json::Value` to `serde_json::Map<String, serde_json::Value>`. This:
- Rejects non-object JSON at parse time (falls through to the `Err`/warn branch)
- Eliminates the `if let` conditional entirely — the `"type"` injection always runs
- Is simpler (fewer lines, no conditional branching)
**Other findings reviewed and deemed clean:**
- No code reuse opportunities missed (no existing JSONL utilities, UUID usage is standard, base64 auth pattern is Segment-specific)
- `lines.is_empty()` guard in `spawn_sender` is correctly defensive (serialization could fail for all tracks)
- `reqwest::Client::new()` per call is acceptable (runs in a one-shot subprocess, matches panic sender pattern)
- `Vec<String>` + `join` is fine for typical 1-5 event batches
- `json!` import in tests is used

View file

@ -0,0 +1,6 @@
{
"status": "success",
"notes": "Stage completed: simplify_opus",
"failure_reason": null,
"timestamp": "2026-03-19T15:27:55.721551+00:00"
}