Playback analytics
audioproxy tells you everything about rendering audio and nothing about
listening to it. That is by design: on a cache hit the proxy redirects the
player to the storage provider or your CDN and never sees another byte, and
even when it does stream the file itself, byte ranges cannot distinguish an
hour of listening from an hour of scrubbing. Playback truth exists in exactly
one place, the <audio> element in your user’s browser. This guide shows how
to collect it there with a small beacon and send it to your own app, using
nothing but browser APIs.
If you are coming from a hosted service like Mux, this replaces the client-side half of its analytics: the part its player SDK did by phoning home. The difference is that the events land in your own database, joined to your own records, with no third party in the loop.
What to record
Section titled “What to record”Four event types cover most product questions about audio:
play, the first time playback starts for a given page view. Answers “how many items get listened to at all”.progress, fired after each fixed amount of actual listening time, say 15 seconds. Summing these gives total listening time; the count per item gives a retention curve. Fire it from the element’stimeupdateevent, but count only time that actually elapsed playing, so a seek from 0:10 to 3:00 does not credit 170 seconds of listening.ended, the element finished on its own. Plays with noendedtell you where people give up.seeked, optional, if skipping behavior matters to you.
Each event should carry the identifier your app already has for the audio, such as the record id you built the proxy URL from. You do not need to parse anything back out of the URL: your app constructed it, so your app knows what it points at.
Sending events: the Beacon API
Section titled “Sending events: the Beacon API”navigator.sendBeacon
is a one-line, fire-and-forget POST that the browser queues even if the page
is being closed. That last property is the reason to prefer it over fetch
for analytics: the moment you most want an event delivered (the user leaving
mid-track) is the moment an ordinary request is most likely to be killed.
navigator.sendBeacon( "/playback_events", JSON.stringify({ event: "progress", item_id: itemId, position: 42.5 }))The first argument is your app’s collection endpoint. The second is the payload; the browser sends it as the POST body. There is no response to read and no promise to await, which is exactly right for telemetry: the player must never wait on analytics.
You only need to reach for sendBeacon directly when you are building the
collection side yourself. If your Rails app already runs
Ahoy, its JavaScript client handles
delivery, batching, and visit attribution for you, so the example below
uses it.
A Stimulus controller
Section titled “A Stimulus controller”For a Rails app with Stimulus and Ahoy,
wrap the audio element in a controller that translates media events into
ahoy.track calls. This one
implements the listening-time bookkeeping described above:
import { Controller } from "@hotwired/stimulus"import ahoy from "ahoy.js"
// Reports play/progress/ended events for the <audio> element inside it.// <div data-controller="playback-beacon"// data-playback-beacon-item-id-value="42">// <audio src="..." controls></audio>// </div>export default class extends Controller { static values = { itemId: String, // your record id, attached to every event interval: { type: Number, default: 15 }, // seconds of listening per progress event }
connect() { this.audio = this.element.querySelector("audio") this.listened = 0 // seconds actually spent playing this.lastTime = null // playback position at the last timeupdate this.started = false
this.audio.addEventListener("play", this.onPlay) this.audio.addEventListener("timeupdate", this.onTimeUpdate) this.audio.addEventListener("ended", this.onEnded) }
disconnect() { this.audio.removeEventListener("play", this.onPlay) this.audio.removeEventListener("timeupdate", this.onTimeUpdate) this.audio.removeEventListener("ended", this.onEnded) }
onPlay = () => { if (this.started) return // report the first start only, not every unpause this.started = true this.send("play") }
onTimeUpdate = () => { const now = this.audio.currentTime if (this.lastTime !== null && !this.audio.paused) { const delta = now - this.lastTime // timeupdate fires a few times per second; a delta larger than a // couple of seconds means the user seeked, so credit no listening. if (delta > 0 && delta < 2) this.listened += delta } this.lastTime = now
if (this.listened >= this.intervalValue) { this.listened -= this.intervalValue this.send("progress", { position: now }) } }
onEnded = () => this.send("ended")
send(event, extra = {}) { ahoy.track(`audio.${event}`, { item_id: this.itemIdValue, ...extra }) }}The values API keeps the controller reusable: the item id and the progress
interval come from data attributes, so one controller serves every player
on the site. The audio. prefix namespaces the events in ahoy_events
next to whatever else your app tracks. The seek guard in onTimeUpdate is
the piece most DIY trackers get wrong; without it, scrubbing inflates
listening time and your retention numbers become fiction.
Nothing else is needed server-side: ahoy.track posts to the endpoint the
Ahoy gem already mounts, and each event arrives with visit and user
attribution attached. Total listening time per item is then one query away:
Ahoy::Event.where(name: "audio.progress") .where_props(item_id: item.id.to_s) .count * 15 # seconds, at the default progress intervalWithout Ahoy
Section titled “Without Ahoy”If you are not running Ahoy, the controller stays identical apart from the
send method, which posts the beacon itself to an endpoint you provide
(add a url value next to itemId for it):
send(event, extra = {}) { navigator.sendBeacon( this.urlValue, JSON.stringify({ event, item_id: this.itemIdValue, ...extra }) )}The endpoint can be as small as a controller that writes a row. Beacon
requests are ordinary POSTs, so the one Rails-specific wrinkle is CSRF:
sendBeacon cannot set custom headers, so skip the token check for this
one endpoint and treat the data accordingly, as untrusted client telemetry.
post "/playback_events", to: "playback_events#create"
# app/controllers/playback_events_controller.rbclass PlaybackEventsController < ApplicationController skip_before_action :verify_authenticity_token
def create payload = JSON.parse(request.body.read) PlaybackEvent.create!( event: payload.fetch("event"), item_id: payload.fetch("item_id"), position: payload["position"], user_id: current_user&.id, user_agent: request.user_agent ) head :no_content endendClient telemetry is trivially forgeable, so treat it as directional product data, not as a billing or royalty ledger. Validate the shape, cap the rate per session, and expect some noise.
What belongs where
Section titled “What belongs where”A complete picture pairs this client-side layer with what your app already knows server-side: it built every proxy URL, so it can log renders requested per item from its own request logs, and cached delivery shows up in your CDN or storage provider’s logs. The beacon covers the one thing neither of those can see, which is what happened after the bytes arrived.