Skip to content

feat(capture): redirect heatmap data from events to heatmaps topic#53514

Draft
pl wants to merge 4 commits intomasterfrom
pl/capture/move_heatmap_data
Draft

feat(capture): redirect heatmap data from events to heatmaps topic#53514
pl wants to merge 4 commits intomasterfrom
pl/capture/move_heatmap_data

Conversation

@pl
Copy link
Copy Markdown
Contributor

@pl pl commented Apr 7, 2026

Problem

The joined analytics pipeline has two branches that both process heatmap data:

  • Events branch: processes all events, extracts $heatmap_data as a side effect alongside full event processing (persons, groups, CDP transforms, event emission)
  • Heatmaps branch: routes only $$heatmap events, skips person processing and event emission, just extracts heatmap data

Non-$$heatmap events (e.g. $pageview) that carry $heatmap_data or scroll depth properties are currently only processed through the events branch, meaning heatmap extraction is coupled to the full event pipeline.

Changes

Rust capture service:

  • When a non-$$heatmap event carries heatmap data ($heatmap_data, or $prev_pageview_pathname + $current_url), capture now produces a second message to the heatmaps topic
  • The redirect message contains only the 7 properties the heatmap pipeline needs, with event name set to $$heatmap and a new UUID
  • The original event has $heatmap_data stripped from its serialized data field (other properties like $prev_pageview_pathname are kept — web analytics queries depend on them)
  • The original event gets a skip_heatmap_processing: true Kafka header so the events branch knows to skip extraction

Node.js ingestion pipeline:

  • extractHeatmapDataStep now checks the skip_heatmap_processing header — when true, it skips extraction and just strips $heatmap_data from the event
  • EventHeaders type extended with skip_heatmap_processing: boolean
  • Kafka header parser updated to read skip_heatmap_processing

Test refactors:

  • Inline EventHeaders constructions across test files replaced with createTestEventHeaders() helper

Once this is rolled out and confirmed stable, we can remove extractHeatmapDataStep from the events branch entirely — heatmap processing will be fully handled by the heatmaps branch.

How did you test this code?

  • 22 Rust unit tests (8 new) covering has_heatmap_data, create_heatmap_redirect, strip_heatmap_data, and process_events integration (redirect creation, no-duplicate for $$heatmap, no-redirect without heatmap data)
  • 23 Node.js unit tests (4 new) for extractHeatmapDataStep covering the skip_heatmap_processing skip path, immutability, and fallback to normal extraction
  • 34 parse-headers tests (2 new) for skip_heatmap_processing header parsing
  • All 362 Rust capture tests and 115 Node.js tests across affected files pass

Publish to changelog?

No

Docs update

N/A

@pl pl marked this pull request as draft April 7, 2026 04:19
@pl pl requested review from a team and pauldambra April 7, 2026 04:19
@assign-reviewers-posthog assign-reviewers-posthog bot requested a review from a team April 7, 2026 04:19
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 7, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: rust/capture/src/events/analytics.rs
Line: 228-252

Comment:
**Data loss when redirect creation fails**

`needs_heatmap_redirect` is built once and is never updated when `create_heatmap_redirect` returns `Err`. The second loop (lines 248–252) still iterates those same `true` flags, so `strip_heatmap_data` is called even for events whose redirect was never actually produced. The original event then has `$heatmap_data` removed with no corresponding redirect on the heatmaps topic — the heatmap data is silently lost.

The fix is to track which redirects were *successfully created* (not which events *needed* one) and only strip those:

```rust
let mut redirect_created: Vec<bool> = vec![false; needs_heatmap_redirect.len()];
let mut heatmap_redirects: Vec<ProcessedEvent> = Vec::new();

for (i, (e, needs_redirect)) in events.iter().zip(needs_heatmap_redirect.iter()).enumerate() {
    if *needs_redirect {
        match create_heatmap_redirect(e, historical_cfg.clone(), context) {
            Ok(processed) => {
                metrics::counter!("capture_heatmap_redirects_created").increment(1);
                heatmap_redirects.push(processed);
                redirect_created[i] = true;   // only set on success
            }
            Err(err) => {
                error!("failed to create heatmap redirect: {err:#}");
                // redirect_created[i] stays false → original is not stripped
            }
        }
    }
}

// …process events…

for (event, did_redirect) in events.iter_mut().zip(redirect_created.iter()) {
    if *did_redirect {
        strip_heatmap_data(event);
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: rust/capture/src/events/analytics.rs
Line: 1053-1062

Comment:
**`strip_heatmap_data` sets `process_heatmap = true` even when deserialization fails**

If `serde_json::from_str` on `event.event.data` fails (unlikely, but possible if data is malformed), the function falls through and still sets `process_heatmap = true`. The downstream Node.js pipeline will then see the flag and skip heatmap extraction, but the raw `data` field still contains `$heatmap_data`. The net result is the heatmap data is silently dropped.

Consider only setting the flag after a successful (or at worst, no-op) strip:

```rust
fn strip_heatmap_data(event: &mut ProcessedEvent) {
    if let Ok(mut raw_event) = serde_json::from_str::<RawEvent>(&event.event.data) {
        raw_event.properties.remove("$heatmap_data");
        if let Ok(data) = serde_json::to_string(&raw_event) {
            event.event.data = data;
        }
        event.metadata.process_heatmap = true;
    }
    // Do not set process_heatmap if we couldn't parse the event;
    // leave it for the downstream pipeline to handle normally.
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "refactor: ignore heatmap data in events ..." | Re-trigger Greptile

Comment on lines +228 to +252
for (e, needs_redirect) in events.iter().zip(needs_heatmap_redirect.iter()) {
if *needs_redirect {
match create_heatmap_redirect(e, historical_cfg.clone(), context) {
Ok(processed) => {
metrics::counter!("capture_heatmap_redirects_created").increment(1);
heatmap_redirects.push(processed);
}
Err(err) => {
error!("failed to create heatmap redirect: {err:#}");
}
}
}
}

let mut events: Vec<ProcessedEvent> = events
.iter()
.map(|e| process_single_event(e, historical_cfg.clone(), context))
.collect::<Result<Vec<ProcessedEvent>, CaptureError>>()?;

// Strip heatmap data from originals that got redirects
for (event, needs_redirect) in events.iter_mut().zip(needs_heatmap_redirect.iter()) {
if *needs_redirect {
strip_heatmap_data(event);
}
}
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.

P1 Data loss when redirect creation fails

needs_heatmap_redirect is built once and is never updated when create_heatmap_redirect returns Err. The second loop (lines 248–252) still iterates those same true flags, so strip_heatmap_data is called even for events whose redirect was never actually produced. The original event then has $heatmap_data removed with no corresponding redirect on the heatmaps topic — the heatmap data is silently lost.

The fix is to track which redirects were successfully created (not which events needed one) and only strip those:

let mut redirect_created: Vec<bool> = vec![false; needs_heatmap_redirect.len()];
let mut heatmap_redirects: Vec<ProcessedEvent> = Vec::new();

for (i, (e, needs_redirect)) in events.iter().zip(needs_heatmap_redirect.iter()).enumerate() {
    if *needs_redirect {
        match create_heatmap_redirect(e, historical_cfg.clone(), context) {
            Ok(processed) => {
                metrics::counter!("capture_heatmap_redirects_created").increment(1);
                heatmap_redirects.push(processed);
                redirect_created[i] = true;   // only set on success
            }
            Err(err) => {
                error!("failed to create heatmap redirect: {err:#}");
                // redirect_created[i] stays false → original is not stripped
            }
        }
    }
}

// …process events…

for (event, did_redirect) in events.iter_mut().zip(redirect_created.iter()) {
    if *did_redirect {
        strip_heatmap_data(event);
    }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: rust/capture/src/events/analytics.rs
Line: 228-252

Comment:
**Data loss when redirect creation fails**

`needs_heatmap_redirect` is built once and is never updated when `create_heatmap_redirect` returns `Err`. The second loop (lines 248–252) still iterates those same `true` flags, so `strip_heatmap_data` is called even for events whose redirect was never actually produced. The original event then has `$heatmap_data` removed with no corresponding redirect on the heatmaps topic — the heatmap data is silently lost.

The fix is to track which redirects were *successfully created* (not which events *needed* one) and only strip those:

```rust
let mut redirect_created: Vec<bool> = vec![false; needs_heatmap_redirect.len()];
let mut heatmap_redirects: Vec<ProcessedEvent> = Vec::new();

for (i, (e, needs_redirect)) in events.iter().zip(needs_heatmap_redirect.iter()).enumerate() {
    if *needs_redirect {
        match create_heatmap_redirect(e, historical_cfg.clone(), context) {
            Ok(processed) => {
                metrics::counter!("capture_heatmap_redirects_created").increment(1);
                heatmap_redirects.push(processed);
                redirect_created[i] = true;   // only set on success
            }
            Err(err) => {
                error!("failed to create heatmap redirect: {err:#}");
                // redirect_created[i] stays false → original is not stripped
            }
        }
    }
}

// …process events…

for (event, did_redirect) in events.iter_mut().zip(redirect_created.iter()) {
    if *did_redirect {
        strip_heatmap_data(event);
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

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.

Doesn't seem like this can happen - it's just the internal interfaces that have error types. I don't want to engage in a big refactoring to break out the logic that doesn't trigger errors.

Comment on lines +1053 to +1062
);
}

#[tokio::test]
async fn test_process_events_creates_heatmap_redirect() {
let now = Utc::now();
let context = create_test_context(now, None);
let events = vec![create_event_with_heatmap_data()];

let sink = Arc::new(MockSink::new());
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.

P2 strip_heatmap_data sets process_heatmap = true even when deserialization fails

If serde_json::from_str on event.event.data fails (unlikely, but possible if data is malformed), the function falls through and still sets process_heatmap = true. The downstream Node.js pipeline will then see the flag and skip heatmap extraction, but the raw data field still contains $heatmap_data. The net result is the heatmap data is silently dropped.

Consider only setting the flag after a successful (or at worst, no-op) strip:

fn strip_heatmap_data(event: &mut ProcessedEvent) {
    if let Ok(mut raw_event) = serde_json::from_str::<RawEvent>(&event.event.data) {
        raw_event.properties.remove("$heatmap_data");
        if let Ok(data) = serde_json::to_string(&raw_event) {
            event.event.data = data;
        }
        event.metadata.process_heatmap = true;
    }
    // Do not set process_heatmap if we couldn't parse the event;
    // leave it for the downstream pipeline to handle normally.
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: rust/capture/src/events/analytics.rs
Line: 1053-1062

Comment:
**`strip_heatmap_data` sets `process_heatmap = true` even when deserialization fails**

If `serde_json::from_str` on `event.event.data` fails (unlikely, but possible if data is malformed), the function falls through and still sets `process_heatmap = true`. The downstream Node.js pipeline will then see the flag and skip heatmap extraction, but the raw `data` field still contains `$heatmap_data`. The net result is the heatmap data is silently dropped.

Consider only setting the flag after a successful (or at worst, no-op) strip:

```rust
fn strip_heatmap_data(event: &mut ProcessedEvent) {
    if let Ok(mut raw_event) = serde_json::from_str::<RawEvent>(&event.event.data) {
        raw_event.properties.remove("$heatmap_data");
        if let Ok(data) = serde_json::to_string(&raw_event) {
            event.event.data = data;
        }
        event.metadata.process_heatmap = true;
    }
    // Do not set process_heatmap if we couldn't parse the event;
    // leave it for the downstream pipeline to handle normally.
}
```

How can I resolve this? If you propose a fix, please make it concise.

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.

That doesn't make much sense to me. First, if the header is set to true, node would process the heatmap data, but we'll remove that branch. Second, if we can't parse the data field, it's unlikely we'll parse it in Node. Parsing into raw event should fail in some other part of capture and discard the event, I assume, but would need to check.

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.

You're right on points 2 and 3 - if the data is malformed enough that Rust can't deserialize it, Node.js won't be able to either, and it should fail validation earlier in the pipeline.

For point 1, I think we're saying the same thing differently: when process_heatmap = true, Node skips extraction (per the PR description). So my concern was that setting the flag when stripping fails means Node would skip extraction even though $heatmap_data is still in the raw data. But as you noted, this will be moot once you remove extractHeatmapDataStep entirely, and the malformed-data scenario is already handled by earlier validation.

Fair pushback - the error path I flagged is either unreachable or benign.

Tip: You can give me more context with .greptile/rules so I get this right next time.

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