Skip to content

Commit 5b6b9a4

Browse files
duracelltomiclaude
andcommitted
Give the VideoPress tracker a memory, so its events carry a time again
Reported against the sandbox: every VideoPress event arrived with a current time and a duration of 0, and no gtm4wp.mediaPlaybackPercentage ever fired. VideoPress does not report playback the way the other embedded players do. Each message carries only its own payload: videopress_timeupdate currentTime / currentTimeMs, NO duration videopress_durationchange duration / durationMs, NO position videopress_playing nothing but event + id videopress_pause nothing but event + id videopress_ended nothing but event + id videopress_seeking nothing but event + id videopress_volumechange nothing but event + id The tracker read currentTimeMs and durationMs off whichever message was in hand, so both were absent on every state change and reported as 0. The percentage path was worse than wrong, it was silent: timeupdate carries no duration, so the zero-duration guard returned before every milestone, and that event simply never fired for VideoPress at all. The tracker now keeps the last reported position and duration per guid and fills every push from that record. Verified against Automattic's postMessage documentation and cross-read against two independent consumers, Jetpack's own player bridge and Sensei's player adapter, which stores the duration in a dataset attribute for exactly this reason. Three smaller corrections found while reading the real vocabulary: * There is no videopress_loadedmetadata, so the ready event never had a trigger of its own. durationchange is the first moment there is a player worth describing, so it is the signal now, held to one push per guid because a quality switch reports the duration again. play, paused and seeked are kept as tolerated aliases, not names the player is known to send. * toggle_fullscreen is the one message the player sends WITHOUT the videopress_ prefix, so the prefix test dropped fullscreen entirely. It is accepted by name now, after the origin check rather than instead of it, and reports mediaPlayerEventParam the way the HTML5 tracker does. toggle-source reports the selected quality level the same way. * A reported position of 0 is now distinguishable from one not reported at all, which the record depends on: rewinding to the start must overwrite the remembered position rather than be discarded as falsy. Every guid-keyed store in the file is null-prototype. The new record is written back THROUGH its lookup, so a guid of __proto__ would resolve to Object.prototype and write a playback position onto every object on the page. The same key also breaks the shared milestone store, whose first-time test reads Object.prototype and then calls indexOf on it; a regression test covers both. The suite was green throughout because its fixtures put currentTimeMs and durationMs on every message, a test double more generous than the player. The fixtures now carry only what each real message carries, and 9 of the tests fail against the previous tracker. Registers U109 with the full vocabulary and the payload distribution, both claims to re-verify, and the note that a generous fixture here is what hid this for a release cycle. VideoPress ships in this same unreleased version, so the changelog edits its existing bullet rather than adding a Fixed: entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dd5fefd commit 5b6b9a4

5 files changed

Lines changed: 487 additions & 104 deletions

File tree

.upstream/upstream-review-checklist.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ No versions, no changelogs, no feeds. Verified by re-checking the recorded **cla
342342
| U104 | **The real Spotify title lives in the embed's `title` attribute, and Spotify prefixes it with the literal `Spotify Embed: `.** The Embed IFrame API carries no title at all — `playback_update` reports only `isPaused`/`isBuffering`/`position`/`duration`/`playingURI` — so the attribute is the primary title source and the raw `spotify:<type>:<id>` URI is what gets reported when it is absent. Two claims, failing differently: (1) **Spotify's oEmbed `html` still carries a `title` attribute** — if it stops, every title silently reverts to the URI (`silent-missing`); (2) **the prefix is still exactly `Spotify Embed: `** — if Spotify rewords it the new prefix leaks into every GA4 **Video Title** row (`silent-wrong`), the worse of the two because the data still looks populated. ⚠️ **The prefix is Spotify's own string, not one WordPress composes.** Core's `wp_filter_oembed_iframe_title_attribute()` (WP 5.2+, `oembed_dataparse`) *keeps* an existing `title` attribute rather than building one, so this is a fixed English literal on every locale and matching it by string is safe — do not "fix" it into a translated-prefix matcher. WP populates the same attribute for every oEmbed provider it handles, but the prefix is per-vendor, which is why this stays a Spotify-scoped row | `js/frontend/gtm4wp-spotify.js` (`gtm4wp_spotify_title_prefix`, `gtm4wp_spotifyTitleFromFrame()`) | fetch `https://open.spotify.com/oembed?url=<content URL>` and read the `title=` attribute out of the returned `html`, then diff it against the sibling top-level `title` field — **the difference between the two IS the prefix**, so the check needs no stored literal to compare against. Pinned from our side by the `title resolution` describe in `js/frontend/test/spotify-tracker.test.js`, which asserts the prefixed form is stripped AND that an unprefixed attribute survives verbatim | — | silent-wrong | quarterly | [x] 2026-08-07 measured across **5 content types, prefix identical on all**: track `Spotify Embed: Never Gonna Give You Up`; album `…: Scorpion`; playlist `…: Today's Top Hits`; show `…: #2536 - Eric Goode &amp; Jeremy McBride`; episode `…: Csukás István - Pom Pom meséi - Festéktüsszentő Hapci Benő`. Live check: a WP-oEmbed'd episode on woo.gtm4wp.dev rendered with the prefixed attribute intact, confirming core preserves rather than rewrites it |
343343
| U105 | **Spotify oEmbed endpoint `https://open.spotify.com/oembed?url=<content URL>`** — the fallback title source, requested from the visitor's browser only when the embed carries no `title` attribute (U104), and at most once per URI per page. Three claims, all silent: (1) it still answers **2xx JSON carrying a string `title`**; (2) it still sends **`access-control-allow-origin: *`** — a CORS tightening alone kills the fallback with nothing on our side to observe, and is the likeliest of the three to happen since it costs Spotify nothing; (3) the `spotify:<type>:<id>` → `https://open.spotify.com/<type>/<id>` mapping the request URL is built from still addresses the content. The endpoint is an **alias**: the response's own `sp_landing` cookie names `open.spotify.com/api/oembed`, so `/oembed` is the front for `/api/oembed` and the two could diverge independently. ⚠️ **This is the only network request any media tracker makes** — the rest of the family is deliberately request-free — so this is also the row to re-read before asserting that property again anywhere | `js/frontend/gtm4wp-spotify.js` (`gtm4wp_spotify_oembed_endpoint`, `gtm4wp_resolveSpotifyTitle()`) | request it with an `Origin` header and read back **both** the body and the CORS response header — a 200 alone answers only claim 1. Pinned from our side by `js/frontend/test/spotify-tracker.test.js`, which asserts the exact request URL and `credentials: 'omit'`, and that a failed lookup is cached so it is never retried | — | silent-missing | quarterly | [x] 2026-08-07 `?url=…/episode/33zC…` sent with `Origin: https://woo.gtm4wp.dev` → **200**, `content-type: application/json`, **`access-control-allow-origin: *`**, body carries `"title":"Csukás István - Pom Pom meséi…"`. Same shape confirmed for track/album/playlist/show. Response sets `sp_t` for `.spotify.com` (`Secure; SameSite=none`), which is why the lookup is issued with `credentials: 'omit'` |
344344
| U68 | VideoPress `postMessage` origin allow-list | `js/frontend/gtm4wp-videopress.js` | Automattic || silent-missing | quarterly | [ ] |
345+
| U109 | **The VideoPress `postMessage` vocabulary, and the fact that each message carries only *its own* payload.** Two halves, and the second is what broke. (1) **Event names.** The player emits `videopress_playing`, `_pause`, `_ended`, `_seeking`, `_volumechange`, `_timeupdate`, `_durationchange`, `_loading_state`, `_toggle-source` and — the one exception — **`toggle_fullscreen` with NO `videopress_` prefix**, which a bare prefix test drops on the floor. There is **no** `videopress_loadedmetadata`, `_play`, `_paused` or `_seeked`; `play` is kept as a tolerated alias only because Sensei's own adapter listens for it. (2) **Payload distribution.** Unlike every other embedded player here, VideoPress does **not** repeat the playback values on each message: `_timeupdate` carries `currentTime`/`currentTimeMs` and **no duration**, `_durationchange` carries `duration`/`durationMs` and **no position**, and every state message carries **neither** — only `event` and `id`. Reading them off the message in hand therefore reports 0 for both on every state change, and starves the percentage tracking of the duration it divides by, so `gtm4wp.mediaPlaybackPercentage` stops firing **entirely**. That is exactly what shipped, and it is the textbook UD-11/UC-3 pair: nothing errors, and the suite was green *because* its fixtures put both fields on every message — a test double more generous than the player. The tracker therefore keeps a per-guid record of the last reported position and duration and feeds that into every push, and `_durationchange` doubles as the ready signal (once per guid — a quality switch reports the duration again) | `js/frontend/gtm4wp-videopress.js`: `gtm4wp_videopress_playback` + `gtm4wp_videoPressPlayback()` (the record), `gtm4wp_videopress_unprefixed_events` (the fullscreen exception), `gtm4wp_videopress_ready` (the once-per-video ready) | https://github.com/Automattic/videopress-player-api-doc/blob/trunk/postmessage.md, cross-read against two independent consumers: Jetpack's `projects/packages/videopress/src/client/lib/player-bridge/index.ts` (the name list) and Sensei's `assets/shared/helpers/player/videopress-adapter.js` (which stores the duration in `element.dataset.duration` for the same reason). Claims to re-verify: **(a) the name list above, `toggle_fullscreen`'s missing prefix included; (b) that `_timeupdate` still carries no duration and the state messages still carry no times.** Pinned from our side by `js/frontend/test/videopress-tracker.test.js`, whose fixtures carry only the properties the real message carries — keep them that way, a generous fixture here is what hid this for a whole release cycle | U68, U102 | silent-wrong | quarterly | [x] 2026-08-10 read from the postMessage doc and both consumers; reproduced against the live sandbox embed `video.wordpress.com/embed/GjfCen6N` on `woo.gtm4wp.dev/videopress-player-test`, where every event reported time 0 / duration 0 and no percentage event fired at all |
345346
| U69 | UA-CH `navigator.userAgentData.getHighEntropyValues()` + Chromium GREASE brand filter regex | `js/frontend/gtm4wp-client-device-data.js` | Chromium UA-CH spec || silent-wrong | quarterly | [ ] |
346347
| U70 | Full-page cache behavior assumptions (LiteSpeed, WP Rocket, Varnish, Cloudflare APO) | `src/Modules/VisitorData/*`, `js/frontend/gtm4wp-visitor-data.js`, `docs/dev/cache-safe-data-layer.md` ||| silent-wrong | on-demand | [ ] |
347348
| U71 | PHP↔JS name contracts: `gtm4wp_item_list_attr`, `gtm4wp_orderid_tracked`, `gtm4wp_woo_event`, `gtm4wp_readded_to_cart`, `div.gtm4wp-wc-visitor-data`, **`gtm4wp_apply_stored_item_list_to_event`** (`Helpers::LIST_ATTRIBUTION_JS_WRAPPER`, emitted by `DataLayer::queue_push()` as a `window.` lookup into the product-detail `view_item` push), the cart-fragment payload's `customer`/`cart` envelope keys, and the three data layer event names in `config.events` (`VisitorDataModule::EVENT_*` ↔ the `resolveEventName()` fallbacks) (UC-4, internal) | `src/Modules/WooCommerce/{Helpers,ProductData,PageDataLayer}.php`, `src/Frontend/DataLayer.php`, `src/Modules/VisitorData/VisitorDataModule.php``js/frontend/*.js` | internal grep; the wrapper name is pinned from both sides (`PageDataLayerTest::test_list_attribution_wrapper_name_matches_the_javascript_export()` reads the JS source; `ecommerce-generic.test.js` asserts the export) || silent-missing | every-run | [ ] |

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Major rewrite of the plugin - please read the announcement post on gtm4wp.com be
124124

125125
### Media events
126126

127-
* Added: media player tracking for eight more players, each as its own opt-in option under Media events → Media players (experimental): Dailymotion, Mixcloud, Cloudflare Stream, Wistia, JW Player, VideoPress, Spotify and Twitch. Each fires the same `gtm4wp.media*` events (ready, state change, playback percentage, player event) as the existing players and also populates Google Tag Manager's built-in Video variables. Notes: Dailymotion tracking rebuilds each embed with Dailymotion's current player, because Dailymotion no longer lets a script listen to a video it did not create itself. The video keeps the size WordPress gave it, and its events report the real video title and channel name; if the player cannot be built for any reason, whether the library fails to load or the player itself refuses to start, the original embed is put back exactly as it was and simply goes untracked, so a tracking problem never costs you the video. An optional **"Dailymotion player ID"** setting picks which player configuration is loaded, taken from the Players tab of a Dailymotion Studio account, and removes Dailymotion's "initialized without a player id" console warning. Leave it empty to use Dailymotion's default player, which needs no account. A periodic playback update is the only signal the Spotify embed exposes, so play/pause/finished states are derived from it, and because the player reports no title either, the track title is read from the embed on the page - when the embed carries none, it is looked up once from Spotify's public oEmbed endpoint. The Cloudflare Stream player reports no title either, so its events take the title from the embed when it carries one, and otherwise report the video's Cloudflare UID. Twitch reports current time and duration for videos (VODs) only, not live streams.
127+
* Added: media player tracking for eight more players, each as its own opt-in option under Media events → Media players (experimental): Dailymotion, Mixcloud, Cloudflare Stream, Wistia, JW Player, VideoPress, Spotify and Twitch. Each fires the same `gtm4wp.media*` events (ready, state change, playback percentage, player event) as the existing players and also populates Google Tag Manager's built-in Video variables. Notes: Dailymotion tracking rebuilds each embed with Dailymotion's current player, because Dailymotion no longer lets a script listen to a video it did not create itself. The video keeps the size WordPress gave it, and its events report the real video title and channel name; if the player cannot be built for any reason, whether the library fails to load or the player itself refuses to start, the original embed is put back exactly as it was and simply goes untracked, so a tracking problem never costs you the video. An optional **"Dailymotion player ID"** setting picks which player configuration is loaded, taken from the Players tab of a Dailymotion Studio account, and removes Dailymotion's "initialized without a player id" console warning. Leave it empty to use Dailymotion's default player, which needs no account. A periodic playback update is the only signal the Spotify embed exposes, so play/pause/finished states are derived from it, and because the player reports no title either, the track title is read from the embed on the page - when the embed carries none, it is looked up once from Spotify's public oEmbed endpoint. The Cloudflare Stream player reports no title either, so its events take the title from the embed when it carries one, and otherwise report the video's Cloudflare UID. VideoPress reports the playback position and the video duration on separate browser messages, and sends neither one alongside a play, pause or finished signal, so the tracker keeps the last value it was given for each video and fills in every event from those; its player-ready event consequently fires when the duration first arrives, once per video, and a fullscreen change is reported as a player event. Twitch reports current time and duration for videos (VODs) only, not live streams.
128128
* Added: tracking for native HTML5 `<video>` and `<audio>` players, as its own opt-in option under Media events → Media players (experimental). Fires the same `gtm4wp.media*` events as the other players - ready (carrying the real media duration), state changes, and playback percentage - and also tracks buffering plus Picture-in-Picture and fullscreen changes on video. Each event also populates Google Tag Manager's built-in Video variables. A local media file carries no title of its own, so the events identify it by its file name, taken without any query string the URL carries: a `?ver=` cache buster or a signed CDN token would otherwise make the same file a different video in your reports every time it changes. The full URL, parameters included, is reported separately.
129129
* Added: an optional **"Track dynamically inserted players"** setting (Media events → Advanced). The media trackers wire up players that are on the page when it loads; with this on they also track players inserted *after* load - opened in a popup/lightbox or loaded via AJAX - which previously went untracked. It works by watching the page for new embeds with a single shared `MutationObserver` (one for all providers, scanning only the nodes each change adds, not the whole document), then running the exact same wiring a player gets at page load; a player whose SDK replaces the embed element with its own iframe (e.g. Spotify) is recognised as already wired, so each player is wired exactly once. **Off by default** (experimental): the observer has a small per-DOM-change cost, so enable it only if your site injects media players at runtime. The Wistia tracker already handled runtime insertion natively and is unchanged. Thanks for the request (#3).
130130
* Added: every media event now fills all eight of Google Tag Manager's built-in **Video variables**, the **Video Visible** one included. This covers all four `gtm4wp.media*` events - not only the state change and playback percentage, which are the two with a Google Tag Manager counterpart, but the player-ready and player-event pushes as well. Those two report an empty **Video Status**, because Google Tag Manager has no status for them; every other Video variable is filled as usual, so a Custom Event trigger on any media event can read the video's title, URL, duration, position and percentage. (The one exception is `gtm4wp.mediaApiReady`, which fires when the player's script finishes loading, before any player exists to describe.) **Video Visible** reports whether the player was actually on screen at the moment of the event: a player scrolled out of view reports `false`, and so does one playing in a background tab or a minimised window, while a player even partly on screen reports `true`. Google publishes no minimum visible percentage for this variable, so no threshold is applied. Two cases cannot be detected by any browser API and follow the page instead: a window fully covered by another window, and a video popped out into Picture-in-Picture. It is measured for every tracked player, including the ones whose embed the player's own script replaces (Spotify) and the one heard from only through browser messages (VideoPress); when a player cannot be located on the page the variable is left unset rather than guessed, so a Custom Event trigger comparing it against `true` behaves the same as it would with GTM's own YouTube trigger.

0 commit comments

Comments
 (0)