Skip to content

fix(browser-plugin-media-tracking): omit media_element entity when no source is attached - #1503

Merged
Matus Tomlein (matus-tomlein) merged 4 commits into
release/4.10.2from
claude/empty-strings-blob-prefixes-1d2612
Sep 9, 2026
Merged

Matus Tomlein (matus-tomlein) merged 4 commits into
release/4.10.2from
claude/empty-strings-blob-prefixes-1d2612

Conversation

@matus-tomlein

@matus-tomlein Matus Tomlein (matus-tomlein) commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

The org.whatwg/media_element schema requires currentSrc and constrains it to format: uri, which an empty string does not satisfy. Both currentSrc and src return empty while no source is attached (networkState = NETWORK_EMPTY), so the entity emitted in that window is rejected as a schema violation and the whole event is dropped as a bad row.

The window matters more than it first appears:

  1. Media Source Extensions (MSE) players never set a src attribute. MSE is the browser API behind all adaptive streaming (HLS/DASH): JavaScript fetches the media itself and feeds bytes to the element, rather than the browser loading a file from a URL. The player creates a MediaSource, assigns it via URL.createObjectURL() — which yields a blob: URL — and attaches it. Between element creation and that attachment, both currentSrc and src are ''. This affects hls.js, dash.js, Shaka Player and Video.js; a plain <video src="movie.mp4"> is never affected.
  2. Tracking commonly starts inside that windowstartMediaTracking is called synchronously in setUpListeners.
  3. Ping events fire on a wall-clock timer from that point regardless of readyState, so the invalid entity recurs for the entire pre-attachment window rather than once.

Reported by a customer running js-4.6.6 with ~17.5k schema_violations bad rows over 7 days, during a limited test-phase rollout.

blob: URLs were never affected

The original report also claimed blob: URLs fail validation. They don't, and no change is needed for them. Verified two ways:

  • Enrich validates via com.networknt:json-schema-validator (pinned 1.5.8 through iglu-scala-client 4.2.1). Its format: uri is UriFormatnew java.net.URI(value)uri.isAbsolute(). blob:https://… parses as scheme blob with an opaque scheme-specific part, so isAbsolute() is truepasses. An empty string parses but has no scheme → fails.
  • The customer's own successfully-tracked events contain blob:https://… values.

Worth noting for future schema work: format is a hard assertion here, not an annotation — Iglu leaves formatAssertionsEnabled unset and the library defaults it to true for dialects below draft 2019-09.

Fix

Omit the entity while there is no source to describe, rather than emitting one the pipeline rejects.

  • entities.tsbuildHTMLMediaElementEntity now returns SelfDescribingJson | null, returning null when neither currentSrc nor src has a value. currentSrc, src, and fileExtension all derive from two locals so no field can serialize as ''.
  • helperFunctions.ts — the data-URI placeholder is now 'data:' instead of 'DATA_URL'. That sentinel was a second, independent violation of the same format: uri constraint: a bare string with no scheme fails for the same reason an empty string does. It has no timing component, so it would have kept producing bad rows even after the empty-string fix.
  • player.tshtmlContext widened to the nullable callback type.

This needs no changes to the context plumbing: DynamicContext callbacks are already typed to return SelfDescribingJson | null, and resolveDynamicContext ends with .filter(Boolean). Returning null drops only that entity — the media event and every other entity are unaffected.

Because the context is resolved per event, this also handles the mid-session cases a caller-side timing workaround cannot: a source swap, load(), or playlist advance that returns the element to NETWORK_EMPTY re-omits the entity, and it reappears automatically once a source reattaches.

Why not relax the schema instead

currentSrc means "the absolute URL of the media resource"; '' means there is no media resource. Encoding "absent" as an empty string that satisfies a URI constraint is the underlying modelling error, and relaxing a shared Iglu Central schema would bless it permanently for every tracker and every downstream consumer. Fixing the emit site keeps the contract intact. A companion dbt change that assumed the schema would be relaxed has been closed as unnecessary.

Testing

  • 43 tests pass (was 38); rollup --failAfterWarnings build clean; rush build succeeds through the dependency chain.
  • New buildHTMLMediaElementEntity unit tests cover: entity omitted with no source, built once a source attaches, blob: sources, currentSrcsrc fallback, and src never serializing as ''.
  • Mutation-checked the key test — with the guard neutered to if (false), omits the entity while no source is attached fails; restored, it passes. It detects the regression rather than passing vacuously.
  • One pre-existing assertion changed intentionally: the DATA_URL test now asserts the valid-URI placeholder.
  • buildHTMLMediaElementEntity and dataUrlHandler are internal to this plugin — no other consumers in the monorepo.
  • The javascript-tracker e2e media test is unaffected: it uses real video files with actual src attributes, so the guard never fires. Not run locally (needs the browser grid) — worth confirming in CI.

Notes for reviewers

  • The 'data:' placeholder is a judgment call. It's valid per RFC 3986 and passes the validator, but it's an odd value to land in the warehouse. Alternatives: data:,, or omitting the optional src field entirely. Happy to change it — I picked the minimal edit that restores validity while preserving the "this was a data URI" signal.
  • buffered[].start is deliberately out of scope. The original report mentions a minimum violation there and calls it secondary. The likely mechanism is start || 0 in timeRangesToObjectArray, which normalizes NaN but passes negatives through against minimum: 0. Needs a real bad row to confirm before fixing.
  • Docs gap worth a follow-up: neither the plugin README nor the public docs mention timing, MSE, or streaming players — the README's only example is a static <video src="…">, where this bug cannot occur. That gap is why this surfaced as a support ticket.

Closes AISP-1701

🤖 Generated with Claude Code

… source is attached

The org.whatwg/media_element schema requires currentSrc and constrains it to
`format: uri`, which an empty string does not satisfy. Both currentSrc and src
return empty while no source is attached (networkState NETWORK_EMPTY) — most
notably between element creation and MediaSource attachment in MSE players such
as hls.js and dash.js, where tracking commonly starts. Ping events fire on a
wall-clock timer from that point regardless of readiness, so the invalid entity
recurs for the whole pre-attachment window and the events are dropped as schema
violations.

Omit the entity while there is no source to describe rather than emitting one the
pipeline rejects. The entity is resolved per event, so it reappears as soon as a
source attaches and is omitted again if the element returns to having no source.
Dynamic context callbacks already permit null and resolveDynamicContext filters
them out, so the media event and all other entities are unaffected.

Also replace the 'DATA_URL' placeholder with 'data:'. That sentinel was a second,
independent violation of the same `format: uri` constraint: a bare string with no
scheme fails validation for the same reason an empty string does.

Note that blob: URLs were never affected — they are valid absolute URIs and pass
validation today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 9, 2026 09:47
The change is a user-facing behaviour fix in a published package, matching
the 'patch' type used for comparable plugin fixes, rather than 'none'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

dataUrlHandler can incorrectly treat non-data URLs containing data: as data URIs, which can mutate valid URLs unexpectedly.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes a schema-validation failure in @snowplow/browser-plugin-media-tracking by omitting the org.whatwg/media_element entity when an HTMLMediaElement has no source attached (i.e., currentSrc/src are empty), preventing events from being dropped as bad rows.

Changes:

  • Make buildHTMLMediaElementEntity return null (and therefore omit the entity) while no media source is attached, and ensure currentSrc/src don’t serialize as ''.
  • Replace the data-URI placeholder from 'DATA_URL' to a URI-valid placeholder ('data:') and update tests accordingly.
  • Widen the player HTML context callback typing to allow nullable dynamic contexts.
File summaries
File Description
plugins/browser-plugin-media-tracking/tests/media.test.ts Adds/updates unit tests for omitted entity behavior and the new data-URI placeholder.
plugins/browser-plugin-media-tracking/src/player.ts Allows nullable dynamic context callbacks so the media_element entity can be omitted when needed.
plugins/browser-plugin-media-tracking/src/helperFunctions.ts Introduces a URI-valid data-URI placeholder constant and uses it in dataUrlHandler.
plugins/browser-plugin-media-tracking/src/entities.ts Omits media_element entity when there is no source, and avoids empty-string URI fields.
common/changes/@snowplow/browser-plugin-media-tracking/media-element-empty-src_2026-09-09-10-30.json Adds changelog entry for the behavioral fix.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugins/browser-plugin-media-tracking/src/helperFunctions.ts Outdated
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dataUrlHandler used a substring check, which was wrong in both directions: it
replaced valid URLs that merely contain 'data:' somewhere (a path segment such
as 'metadata:9' matches) and it missed an uppercase 'DATA:' scheme, shipping the
large base64 payload the function exists to avoid. URI schemes are
case-insensitive per RFC 3986.

Match /^data:/i instead, and cover both directions with tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@matus-tomlein
Matus Tomlein (matus-tomlein) changed the base branch from master to release/4.10.2 September 9, 2026 11:32
@matus-tomlein
Matus Tomlein (matus-tomlein) merged commit 3239c4f into release/4.10.2 Sep 9, 2026
8 checks passed
@matus-tomlein
Matus Tomlein (matus-tomlein) deleted the claude/empty-strings-blob-prefixes-1d2612 branch September 9, 2026 11:38
@github-actions github-actions Bot mentioned this pull request Sep 9, 2026
Matus Tomlein (matus-tomlein) added a commit that referenced this pull request Sep 9, 2026
… source is attached (#1503)

## Problem

The `org.whatwg/media_element` schema requires `currentSrc` and
constrains it to `format: uri`, which an **empty string does not
satisfy**. Both `currentSrc` and `src` return empty while no source is
attached (`networkState` = `NETWORK_EMPTY`), so the entity emitted in
that window is rejected as a schema violation and the whole event is
dropped as a bad row.

The window matters more than it first appears:

1. **Media Source Extensions (MSE) players never set a `src`
attribute.** [MSE](https://www.w3.org/TR/media-source-2/) is the browser
API behind all adaptive streaming (HLS/DASH): JavaScript fetches the
media itself and feeds bytes to the element, rather than the browser
loading a file from a URL. The player creates a `MediaSource`, assigns
it via `URL.createObjectURL()` — which yields a `blob:` URL — and
attaches it. Between element creation and that attachment, both
`currentSrc` and `src` are `''`. This affects hls.js, dash.js, Shaka
Player and Video.js; a plain `<video src="movie.mp4">` is never
affected.
2. **Tracking commonly starts inside that window** —
`startMediaTracking` is called synchronously in `setUpListeners`.
3. **Ping events fire on a wall-clock timer** from that point regardless
of `readyState`, so the invalid entity recurs for the entire
pre-attachment window rather than once.

Reported by a customer running js-4.6.6 with ~17.5k `schema_violations`
bad rows over 7 days, during a limited test-phase rollout.

### `blob:` URLs were never affected

The original report also claimed `blob:` URLs fail validation. They
don't, and no change is needed for them. Verified two ways:

- Enrich validates via `com.networknt:json-schema-validator` (pinned
1.5.8 through iglu-scala-client 4.2.1). Its `format: uri` is `UriFormat`
→ `new java.net.URI(value)` → `uri.isAbsolute()`. `blob:https://…`
parses as scheme `blob` with an opaque scheme-specific part, so
`isAbsolute()` is `true` → **passes**. An empty string parses but has no
scheme → **fails**.
- The customer's own successfully-tracked events contain
`blob:https://…` values.

Worth noting for future schema work: `format` is a **hard assertion**
here, not an annotation — Iglu leaves `formatAssertionsEnabled` unset
and the library defaults it to `true` for dialects below draft 2019-09.

## Fix

Omit the entity while there is no source to describe, rather than
emitting one the pipeline rejects.

- **`entities.ts`** — `buildHTMLMediaElementEntity` now returns
`SelfDescribingJson | null`, returning `null` when neither `currentSrc`
nor `src` has a value. `currentSrc`, `src`, and `fileExtension` all
derive from two locals so no field can serialize as `''`.
- **`helperFunctions.ts`** — the data-URI placeholder is now `'data:'`
instead of `'DATA_URL'`. That sentinel was a **second, independent**
violation of the same `format: uri` constraint: a bare string with no
scheme fails for the same reason an empty string does. It has no timing
component, so it would have kept producing bad rows even after the
empty-string fix.
- **`player.ts`** — `htmlContext` widened to the nullable callback type.

This needs no changes to the context plumbing: `DynamicContext`
callbacks are already typed to return `SelfDescribingJson | null`, and
`resolveDynamicContext` ends with `.filter(Boolean)`. Returning `null`
drops only that entity — the media event and every other entity are
unaffected.

Because the context is resolved **per event**, this also handles the
mid-session cases a caller-side timing workaround cannot: a source swap,
`load()`, or playlist advance that returns the element to
`NETWORK_EMPTY` re-omits the entity, and it reappears automatically once
a source reattaches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants