-
Notifications
You must be signed in to change notification settings - Fork 318
Heartbeat ak #485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Heartbeat ak #485
Conversation
define a heartbeat() top level method that aggregates events client-side and flushes them to mixpanel as a single event. depends on an eventName and a contentId.
oops; it didn't like my training space i guess.
adding settings for the mocha test runner in vscode ... so i can click my mouse until the tests go green, and so should you.
now that we have $duration + $heartbeats ... we need to expect them
just trying to be as consistent as possible with the rest of the module. also better defaults
customer use case thread: https://mixpanel.slack.com/archives/C08CJKNNURW/p1749733041454079 internal docs: https://www.notion.so/mxpnl/Heartbeat-Events-212e0ba9256280e883dbed10e3b31e87 |
converting to draft state following feedback from @tdumitrescu and @jakewski to simplify the module's API and maybe just make it a method like: |
the new heartbeat API will go from 6 public methods and 5+ configuration options down to 1 method + 2 options 👍 (importantly, we will not expose a "flush" interface and just do timeout or page unload based-flushing) /**
* @function heartbeat
* @memberof mixpanel
* @param {String} eventName The name of the event to track
* @param {String} contentId Unique identifier for the content being tracked
* @param {Object} [props] Properties to aggregate with existing data
* @param {Object} [options] Configuration options
* @param {Number} [options.timeout] Timeout in milliseconds (default 30000)
* @param {Boolean} [options.forceFlush] Force immediate flush after aggregation
* */ |
basically just heartbeat(ev, id, props, opts) ... so a much smaller surface area.
transitioning back to ready for review @tdumitrescu we have greatly simplified the API; it's one method unit tests + integration tests should be comprehensive. thanks for the guidance! |
tests/unit/heartbeat.js
Outdated
expect(lib.report_error).to.have.been.calledWith('heartbeat: eventName and contentId are required'); | ||
}); | ||
|
||
it('should convert eventName and contentId to strings', function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the mock lib you have here seems a bit too exhaustive. isn't this test case literally testing against the code you wrote up L203? hard to tell which part of the heartbeat functionality in the main lib is getting tested
MixpanelLib is hard to test in the node env because it's pretty heavily tied to various browser APIs (not that it can't be done, but we've always favored browser tests to help catch real-world scenarios) - probably why you needed this super comprehensive mock right?
that being said - having unit tests are super reliable and easy to iterate on. maybe there's a way we can encapsulate the heartbeat functionality and inject the dependencies it needs from MixpanelLIb - e.g. make heartbeat a separate module that MixpanelLib uses under the hood, and allow that module to be tested in this file.
// MixpanelLib
this.heartbeat = new MixpanelHeartBeat({ // this is imported a different file
onFlushEvent: function (ev, props) { ... },
persistence: this.persistence...
})
then we can mock out those args, and test actual business logic in here. less LOCs in mixpanel-core is a plus - what do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jakewski thank you so much for the notes!
-
mock was created this way to ensure that any config changes / GDPR are respected and also that the track calls are correct
-
strong agree that less LOC in mixpanel-core is better, but i imagined as a separate file this starts to feel like a plugin rather than just convenience method
-
is there an example of this type dependency injection elsewhere in the repo i could borrow from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- I'm not sure that's true - the mock is reimplementing a lot of functionality so you'd basically be rewriting the mock when you need to do a config change
- feeling like a plugin to us as the developers is a good thing. it isolates this functionality so that it's easy to find and allows us to test the business logic without worrying about the rest of the lib. to the end user it can look exactly the same
- good examples are autocapture, session recording
src/mixpanel-core.js
Outdated
MixpanelLib.prototype._heartbeat_save_storage = function(data) { | ||
var current_props = {}; | ||
current_props[HEARTBEAT_QUEUE_KEY] = data; | ||
this['persistence'].register(current_props); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know that persisting heartbeat data in the cookie is a great move here. If this system really needs browser persistence, we can probably hook into the existing batch queue utils with some modification, which support indexeddb and localstorage. That might be another overall approach too: make heartbeats a special queue in the request batcher, an event which doesn't get flushed on a regular schedule, and every time a new heartbeat happens, the queued event is rewritten. @jakewski any thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh yeah this persistence
can be cookie or localstorage depending on config right - I agree that cookie is weird for this.
I like the idea of a new RequestBatcher
for heartbeat events specifically, but trying to rewrite the event for each heartbeat might be difficult and go against the design of the queue.
maybe we can queue up all heartbeats, and do the aggregation only once it's time to flush? I think that design would have some benefits on its own and potentially just use indexeddb off the bat to make sure there's no storage limit issues
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that would work well - queue heartbeats like any other event, but consolidate them when flushing. (Hardest part would be working out deduplication in case you need to re-send; maybe the summary event's Insert ID would be deterministic based on the heartbeats it's rolling up?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removing persistence for now. i forgot about cookie persistence pattern. such a gotcha.
i think we can persistence back if it's actually requested (i.e. the 'best effort' flush on unload is not good enough)
* - Objects are merged (latest overwrites) | ||
* - Arrays have elements appended | ||
* | ||
* Events auto-flush after 30 seconds (configurable) or on page unload. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is that every 30 seconds or after 30 seconds of inactivity?
Maybe I've missed it/misunderstood, but I'm surprised this method doesn't have an option for the SDK to repeat heartbeats on a regular schedule, so you don't have to keep calling heartbeat
over and over yourself e.g.
const videoHeartbeat = mixpanel.heartbeat(
'podcast_listen', 'episode_123',
{platform: 'mobile'},
{interval_seconds: 5},
);
// then when video stops:
videoHeartbeat.stop();
(I haven't thought through that signature too much ^^^ but something like that I imagine would let you just call start/stop methods and not worry about managing your own timers)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
heard. 🤔
heartbeat() events should flush on page unload (best effort). so we don't actually need persistence and this greatly reduces the complexity of the code
spy on mixpanel.track to ensure heartbeat works the way we want (and don't reimplement all of its functionality in the test)
thank you both for the thoughtful discussion. i had some time to work through this today, and i decided:
i still have to think about @tdumitrescu's comment on providing explicit the simplest example would be the <video id="my-video" width="640" height="360" controls>
<source src="your-video-file.mp4" type="video/mp4">
</video>
<script>
const videoElement = document.getElementById('my-video');
const videoId = 'product_demo_123';
videoElement.addEventListener('timeupdate', () => {
if (!videoElement.paused) {
mixpanel.heartbeat('video_watch', videoId);
}
});
</script> this pattern is well-supported by video.js and vimeo's player SDK however, if i get @tdumitrescu's point properly, this approach likely requires boilerplate to work with other third party plugins like youtube's iframe API: // this is what we don't want
let heartbeatInterval;
function onYouTubeIframeAPIReady() {
new YT.Player('player', {
videoId: 'foo',
events: {
'onStateChange': onPlayerStateChange
}
});
}
function onPlayerStateChange(event) {
const videoId = 'foo';
// If the video is now playing, start the heartbeat loop
if (event.data == YT.PlayerState.PLAYING) {
// Clear any existing interval to be safe
clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
console.log('YouTube heartbeat...');
mixpanel.heartbeat('video_watch', videoId);
}, 5000); // oy!
}
else {
clearInterval(heartbeatInterval);
}
} in this case it would be easier to just: if (event.data == YT.PlayerState.PLAYING) {
mixpanel.heartbeat.start('video_watch', videoId);
}
else {
mixpanel.heartbeat.stop('video_watch', videoId);
} and call it a day... but then we have to stub (( would it be ok to support both APIs? is it a problem for thoughts? (also why are int tests failing even though i didn't make any changes to them in the last commit?!) |
this PR adds a new
.heartbeat()
method to the SDK. it aggregates values client-side and sends a single event on flush:this is useful for streaming or buffering events (video + audio players) where you don't want to send each heartbeat to mixpanel, and you only care about the total aggregated "watch time" or "listen time".
therefore,
heartbeat()
can safely be called in a loop, and solves a common challenge of summarizing events before sending to mixpanel.this PR also adds some vscode quality-of-life features to repo, like "one click tests" etc...