← Back to Metrics

Content Discovery-to-Play Rate

Also known as: Browse-to-Play Rate, Discovery-to-Play Conversion Rate, Impression-to-Play Rate, Content Take Rate, Discovery-to-Playback Rate

ConversionmetricMedia & Streaming: Content Discovery & PlaybackMedia & StreamingMedia

Percentage of distinct title discovery exposures (a title surfaced to a viewer in a search, browse, recommendation or editorial list, counted once per session) that are followed by a playback start of that same title in the same session within 30 minutes of the first exposure. It is the streaming industry's browse-to-play conversion measure and shows how well the catalogue presentation, ranking and artwork turn what a viewer is shown into what they actually watch.

Business Use Case

1️⃣ Personalisation & Recommendations team: when a ranking-model variant in an A/B test shows a statistically significant drop in the rate on the home recommendation rails versus control, they stop the traffic ramp and roll back to the control model before full release. 2️⃣ Content Programming: when a newly launched title sits in the bottom decile of its genre for the rate during its first 72 hours despite heavy exposure, the programming lead commissions an artwork and synopsis refresh and moves the title to a higher-intent editorial collection. 3️⃣ Search product team: when the search-surface rate falls week over week after a search index or relevance release, they audit top no-play queries, add synonyms and title aliases, and re-rank results before the next release. 4️⃣ Product (TV & Devices): when one device platform or app version trails the cross-platform rate by a sustained margin, the product manager opens a navigation or player-launch defect with the platform engineering squad and gates the next app release on closing the gap.

Business Criticality

High

Journey / Navigation Area

Discovery

Owning Function

Product

Scope

Session

Formula

(COUNT(DISTINCT session × title exposures from view_item_list WHERE discovery_source IN (search, browse, recommendation, editorial) AND tile item_variant NOT IN (trailer, preview), with title = content_series_id falling back to content_id / item_id, WHERE a video_start of the same title (content_series_id falling back to content_id) with play_trigger NOT IN (autoplay, next_episode) AND content_type NOT IN (trailer, preview) occurs in the same session at or after the first exposure and within 30 minutes of it) / COUNT(DISTINCT session × title exposures from view_item_list with the same discovery_source and trailer/preview rules)) × 100

Calculation Notes

1️⃣ Grain: one exposure = one distinct session_id × title pair, timestamped at the first view_item_list impression of that title in the session; the title key is content_series_id, falling back to content_id (item_id in GA4, SKU in Adobe) for films, so episode and season tiles roll up to the parent series and the same title shown on several rails collapses to a single exposure. Trailer and preview tiles (item_variant / content_type = trailer or preview) are excluded from the denominator. When broken down by discovery_source or rail, the grain becomes session × title × surface and credit goes to the last surface exposed before the play, so breakdown rows do not sum to the total. 2️⃣ Denominator eligibility: only viewable impressions count, so view_item_list (GA4) / content:impression (Adobe) must fire only when a tile meets the 50%/1s rule (at least 50% of tile pixels in view for at least 1 second on web and mobile; on connected TV, the tile rendered in a row that received focus); lazy-loaded tiles never scrolled into view send nothing, and the queries count every impression sent. Allowed discovery_source values are search, browse, recommendation, editorial, hero, continue_watching and deep_link; only search, browse, recommendation and editorial count, while hero (billboards that play previews), continue_watching (measures resumption rather than discovery) and deep_link are excluded. 3️⃣ Numerator: an exposure converts when a user-initiated video_start (media.sessionStart in Adobe; play_trigger such as user_click, never autoplay or next_episode) for the same title key fires on the first rendered frame of main content in the same session, at or after the first exposure and no later than 30 minutes after it. The navigation path between tile and play (select_content / select_item, a view_item detail page or a tile play button) is not checked: the exposure-before-start and same-session rules plus the user-initiated play_trigger define the conversion, and deep-link, push or email starts without an in-app exposure never convert because no exposure exists. Multiple starts against one exposure count once; a video_start that precedes the first exposure never converts it. 4️⃣ Exclusions: autoplay and auto-advance starts (play_trigger = autoplay or next_episode), trailer and preview playback (content_type = trailer or preview), internal traffic identified by network (GA4 traffic_type = internal set by the internal traffic IP rule; in AEP the same office IP ranges on environment.ipV4), and bots (removed by the GA4 known-bot filter at collection; in AEP by a bot, crawl, spider or headless user-agent pattern). 5️⃣ Session rules: 30-minute inactivity timeout (GA4 default); on connected TV and mobile, playback heartbeats keep the session alive, and an app backgrounded for more than 30 minutes starts a new session. An exposure belongs to the reporting-timezone date of its first impression even if the play lands after midnight, and late-arriving video_start hits are accepted for 72 hours before the day is closed and restated. 6️⃣ Entitlement and failures: exposures to titles the viewer cannot play (not in plan, rental not purchased, geo-restricted) remain in the global denominator; publish a separate entitled-only cut. Play attempts that fail before the first frame emit no video_start and stay unconverted, so read the rate alongside Playback Failure Rate before attributing a drop to discovery quality. 7️⃣ Keep distinct from Click-to-Play Rate (select_content denominator) and Content Detail-to-Play Rate (view_item denominator): impression-based rates run far lower and are sensitive to tile density, so annotate the trend whenever row count, tiles per row or page layout changes, and suppress rail-level values below 1,000 daily exposures.

Technical Details

Measure TypePercentage
Aggregation WindowDaily, Weekly, Monthly

Events

PlatformEvent
Google Analytics 4view_item_list
Adobecontent:impression

Data Mappings

W3 Data Layer
{
  "digitalData": {
    "event": [
      {
        "eventInfo": {
          "eventName": "view_item_list",
          "eventAction": "content:impression",
          "type": "impression"
        },
        "category": {
          "primaryCategory": "content discovery"
        },
        "attributes": {
          "item_list_id": "rail-trending-now",
          "item_list_name": "Trending Now",
          "discovery_source": "recommendation",
          "discovery_surface": "home",
          "recommendation_model_variant": "ranker-v7-treatment",
          "items": [
            {
              "content_id": "series-204",
              "content_name": "Example Series",
              "genre": "drama",
              "content_type": "series",
              "position": 3,
              "content_series_id": "series-204",
              "entitlement_status": "entitled"
            },
            {
              "content_id": "title-12345",
              "content_name": "Example Film",
              "genre": "thriller",
              "content_type": "film",
              "position": 4,
              "content_series_id": null,
              "entitlement_status": "not_in_plan"
            }
          ]
        }
      },
      {
        "eventInfo": {
          "eventName": "video_start",
          "eventAction": "video:start",
          "type": "playback"
        },
        "category": {
          "primaryCategory": "content playback"
        },
        "attributes": {
          "content_id": "episode-204-02-05",
          "content_series_id": "series-204",
          "content_type": "episode",
          "play_trigger": "user_click",
          "video_title": "Example Series S2 E5",
          "video_provider": "example_player",
          "video_duration": 2700,
          "discovery_source": "recommendation",
          "discovery_surface": "home",
          "item_list_id": "rail-trending-now"
        }
      }
    ]
  }
}
GA4 Data Layer
{
  "view_item_list": {
    "event": "view_item_list",
    "discovery_source": "recommendation",
    "discovery_surface": "home",
    "recommendation_model_variant": "ranker-v7-treatment",
    "ecommerce": {
      "item_list_id": "rail-trending-now",
      "item_list_name": "Trending Now",
      "items": [
        {
          "item_id": "series-204",
          "item_name": "Example Series",
          "item_category": "drama",
          "item_variant": "series",
          "index": 3,
          "content_series_id": "series-204",
          "entitlement_status": "entitled"
        },
        {
          "item_id": "title-12345",
          "item_name": "Example Film",
          "item_category": "thriller",
          "item_variant": "film",
          "index": 4,
          "content_series_id": null,
          "entitlement_status": "not_in_plan"
        }
      ]
    }
  },
  "video_start": {
    "event": "video_start",
    "content_id": "episode-204-02-05",
    "content_series_id": "series-204",
    "content_type": "episode",
    "play_trigger": "user_click",
    "video_title": "Example Series S2 E5",
    "video_provider": "example_player",
    "video_duration": 2700,
    "discovery_source": "recommendation",
    "discovery_surface": "home",
    "item_list_id": "rail-trending-now"
  }
}
Adobe Client Data Layer
{
  "content:impression": {
    "event": "content:impression",
    "eventInfo": {
      "path": "content.list"
    },
    "content": {
      "list": {
        "item_list_id": "rail-trending-now",
        "item_list_name": "Trending Now",
        "discovery_source": "recommendation",
        "discovery_surface": "home",
        "recommendation_model_variant": "ranker-v7-treatment"
      },
      "items": [
        {
          "content_id": "series-204",
          "content_name": "Example Series",
          "genre": "drama",
          "content_type": "series",
          "position": 3,
          "content_series_id": "series-204",
          "entitlement_status": "entitled"
        },
        {
          "content_id": "title-12345",
          "content_name": "Example Film",
          "genre": "thriller",
          "content_type": "film",
          "position": 4,
          "content_series_id": null,
          "entitlement_status": "not_in_plan"
        }
      ]
    }
  },
  "video:start": {
    "event": "video:start",
    "eventInfo": {
      "path": "media.playback"
    },
    "media": {
      "playback": {
        "content_id": "episode-204-02-05",
        "content_series_id": "series-204",
        "content_type": "episode",
        "play_trigger": "user_click",
        "video_title": "Example Series S2 E5",
        "video_provider": "example_player",
        "video_duration": 2700,
        "discovery_source": "recommendation",
        "discovery_surface": "home",
        "item_list_id": "rail-trending-now"
      }
    }
  }
}
XDM Mapping
{
  "view_item_list": {
    "eventType": "commerce.productListViews",
    "commerce": {
      "productListViews": {
        "value": 1
      }
    },
    "productListItems": [
      {
        "SKU": "series-204",
        "name": "Example Series",
        "productCategories": [
          {
            "categoryName": "drama"
          }
        ],
        "_experience": {
          "analytics": {
            "customDimensions": {
              "eVars": {
                "eVar11": "series",
                "eVar12": "3",
                "eVar13": "series-204",
                "eVar14": "entitled"
              }
            }
          }
        }
      },
      {
        "SKU": "title-12345",
        "name": "Example Film",
        "productCategories": [
          {
            "categoryName": "thriller"
          }
        ],
        "_experience": {
          "analytics": {
            "customDimensions": {
              "eVars": {
                "eVar11": "film",
                "eVar12": "4",
                "eVar14": "not_in_plan"
              }
            }
          }
        }
      }
    ],
    "_tenant": {
      "discovery": {
        "listId": "rail-trending-now",
        "listName": "Trending Now",
        "discoverySource": "recommendation",
        "discoverySurface": "home",
        "recommendationModelVariant": "ranker-v7-treatment"
      }
    }
  },
  "video_start": {
    "eventType": "media.sessionStart",
    "mediaCollection": {
      "playhead": 0,
      "sessionDetails": {
        "name": "episode-204-02-05",
        "friendlyName": "Example Series S2 E5",
        "length": 2700,
        "contentType": "vod",
        "streamType": "video",
        "playerName": "example_player"
      }
    },
    "mediaTimed": {
      "primaryAssetReference": {
        "_id": "episode-204-02-05"
      },
      "starts": {
        "value": 1
      }
    },
    "_tenant": {
      "playback": {
        "contentId": "episode-204-02-05",
        "contentSeriesId": "series-204",
        "contentType": "episode",
        "playTrigger": "user_click",
        "discoverySource": "recommendation",
        "discoverySurface": "home",
        "listId": "rail-trending-now"
      }
    }
  }
}

SQL Queries

Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
-- Content Discovery-to-Play Rate = session x title exposures followed by a user-initiated video_start of the same title within 30 minutes / all eligible session x title exposures x 100
-- Set before running: reporting time zone and reporting date range (end the range at least 3 days ago so late video_start hits have landed)
DECLARE reporting_tz STRING DEFAULT 'America/New_York';
DECLARE start_date DATE DEFAULT DATE_SUB(CURRENT_DATE('America/New_York'), INTERVAL 30 DAY);
DECLARE end_date DATE DEFAULT DATE_SUB(CURRENT_DATE('America/New_York'), INTERVAL 3 DAY);
DECLARE play_window_micros INT64 DEFAULT 30 * 60 * 1000000;

WITH base AS (
  SELECT
    user_pseudo_id,
    event_name,
    event_timestamp,
    COALESCE(device.category, '(not set)') AS device_cat,
    items,
    (SELECT ep.value.int_value FROM UNNEST(event_params) AS ep WHERE ep.key = 'ga_session_id') AS ga_session_id,
    (SELECT ep.value.string_value FROM UNNEST(event_params) AS ep WHERE ep.key = 'traffic_type') AS traffic_type,
    (SELECT LOWER(ep.value.string_value) FROM UNNEST(event_params) AS ep WHERE ep.key = 'discovery_source') AS discovery_source,
    (SELECT NULLIF(COALESCE(ep.value.string_value, CAST(ep.value.int_value AS STRING)), '') FROM UNNEST(event_params) AS ep WHERE ep.key = 'content_id') AS content_id,
    (SELECT NULLIF(COALESCE(ep.value.string_value, CAST(ep.value.int_value AS STRING)), '') FROM UNNEST(event_params) AS ep WHERE ep.key = 'content_series_id') AS content_series_id,
    (SELECT LOWER(ep.value.string_value) FROM UNNEST(event_params) AS ep WHERE ep.key = 'content_type') AS content_type,
    (SELECT LOWER(ep.value.string_value) FROM UNNEST(event_params) AS ep WHERE ep.key = 'play_trigger') AS play_trigger
  FROM `your-project.analytics_123456789.events_*`
  -- Two extra export days on each side cover time zone shifts and plays that land after midnight
  WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(start_date, INTERVAL 2 DAY))
                          AND FORMAT_DATE('%Y%m%d', DATE_ADD(end_date, INTERVAL 2 DAY))
    AND event_name IN ('view_item_list', 'video_start')
),
scoped AS (
  SELECT
    *,
    CONCAT(user_pseudo_id, '.', CAST(ga_session_id AS STRING)) AS session_key
  FROM base
  WHERE user_pseudo_id IS NOT NULL
    AND ga_session_id IS NOT NULL
    -- Internal traffic flagged by the GA4 internal traffic rule (office IP ranges); known bots are removed by GA4 at collection
    AND COALESCE(traffic_type, '') <> 'internal'
),
exposure_items AS (
  -- One row per title tile impression; episodes roll up to the parent series through content_series_id
  SELECT
    s.session_key,
    s.event_timestamp,
    s.device_cat,
    s.discovery_source,
    COALESCE(
      (SELECT NULLIF(COALESCE(ip.value.string_value, CAST(ip.value.int_value AS STRING)), '') FROM UNNEST(i.item_params) AS ip WHERE ip.key = 'content_series_id'),
      i.item_id
    ) AS title_key,
    (SELECT LOWER(ip.value.string_value) FROM UNNEST(i.item_params) AS ip WHERE ip.key = 'entitlement_status') AS entitlement_status
  FROM scoped AS s
  CROSS JOIN UNNEST(s.items) AS i
  WHERE s.event_name = 'view_item_list'
    -- Discovery surfaces only: hero billboard and continue-watching sources are outside this list
    AND s.discovery_source IN ('search', 'browse', 'recommendation', 'editorial')
    AND COALESCE(LOWER(i.item_variant), '') NOT IN ('trailer', 'preview')
    AND i.item_id IS NOT NULL
),
exposures AS (
  -- Denominator grain: distinct session x title, timestamped at the first impression in the session
  SELECT
    session_key,
    title_key,
    MIN(event_timestamp) AS first_exposure_ts,
    ARRAY_AGG(STRUCT(device_cat, entitlement_status) ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS first_ctx
  FROM exposure_items
  GROUP BY session_key, title_key
),
plays AS (
  -- User-initiated main-content starts only
  SELECT
    session_key,
    COALESCE(content_series_id, content_id) AS title_key,
    event_timestamp AS play_ts
  FROM scoped
  WHERE event_name = 'video_start'
    AND COALESCE(play_trigger, '') NOT IN ('autoplay', 'next_episode')
    AND COALESCE(content_type, '') NOT IN ('trailer', 'preview')
    AND COALESCE(content_series_id, content_id) IS NOT NULL
),
first_play AS (
  -- Numerator: first qualifying start of the same title in the same session, from the first exposure up to 30 minutes later; repeat starts count once
  SELECT
    e.session_key,
    e.title_key,
    MIN(p.play_ts) AS first_play_ts
  FROM exposures AS e
  JOIN plays AS p
    ON p.session_key = e.session_key
   AND p.title_key = e.title_key
   AND p.play_ts >= e.first_exposure_ts
   AND p.play_ts <= e.first_exposure_ts + play_window_micros
  GROUP BY e.session_key, e.title_key
),
title_level AS (
  SELECT
    e.session_key,
    e.title_key,
    DATE(TIMESTAMP_MICROS(e.first_exposure_ts), reporting_tz) AS report_date,
    e.first_ctx.device_cat AS device_cat,
    e.first_ctx.entitlement_status AS entitlement_status,
    fp.first_play_ts
  FROM exposures AS e
  LEFT JOIN first_play AS fp
    ON fp.session_key = e.session_key
   AND fp.title_key = e.title_key
),
credited_source AS (
  -- Breakdown credit: the last discovery source that exposed the title at or before the converting play
  SELECT
    t.session_key,
    t.title_key,
    ARRAY_AGG(x.discovery_source ORDER BY x.event_timestamp DESC LIMIT 1)[OFFSET(0)] AS credited_discovery_source
  FROM title_level AS t
  JOIN exposure_items AS x
    ON x.session_key = t.session_key
   AND x.title_key = t.title_key
  WHERE t.first_play_ts IS NOT NULL
    AND x.event_timestamp <= t.first_play_ts
  GROUP BY t.session_key, t.title_key
),
source_level AS (
  SELECT DISTINCT
    session_key,
    title_key,
    discovery_source
  FROM exposure_items
),
unified AS (
  -- Total rows: session x title grain
  SELECT
    report_date,
    'all_sources' AS discovery_source,
    device_cat,
    entitlement_status,
    CONCAT(session_key, '|', title_key) AS exposure_key,
    first_play_ts IS NOT NULL AS converted
  FROM title_level
  UNION ALL
  -- Breakdown rows: session x title x discovery source grain, so they do not sum to the total
  SELECT
    t.report_date,
    s.discovery_source,
    t.device_cat,
    t.entitlement_status,
    CONCAT(s.session_key, '|', s.title_key, '|', s.discovery_source) AS exposure_key,
    COALESCE(c.credited_discovery_source = s.discovery_source, FALSE) AS converted
  FROM source_level AS s
  JOIN title_level AS t
    ON t.session_key = s.session_key
   AND t.title_key = s.title_key
  LEFT JOIN credited_source AS c
    ON c.session_key = s.session_key
   AND c.title_key = s.title_key
)
SELECT
  report_date,
  discovery_source,
  IF(GROUPING(device_cat) = 1, 'all_devices', device_cat) AS device_category,
  COUNT(DISTINCT exposure_key) AS exposures,
  COUNT(DISTINCT IF(converted, exposure_key, NULL)) AS converted_exposures,
  ROUND(SAFE_DIVIDE(COUNT(DISTINCT IF(converted, exposure_key, NULL)), COUNT(DISTINCT exposure_key)) * 100, 2) AS content_discovery_to_play_rate,
  COUNT(DISTINCT IF(entitlement_status = 'entitled', exposure_key, NULL)) AS entitled_exposures,
  COUNT(DISTINCT IF(converted AND entitlement_status = 'entitled', exposure_key, NULL)) AS entitled_converted_exposures,
  ROUND(SAFE_DIVIDE(COUNT(DISTINCT IF(converted AND entitlement_status = 'entitled', exposure_key, NULL)), COUNT(DISTINCT IF(entitlement_status = 'entitled', exposure_key, NULL))) * 100, 2) AS entitled_discovery_to_play_rate
FROM unified
WHERE report_date BETWEEN start_date AND end_date
GROUP BY GROUPING SETS ((report_date, discovery_source), (report_date, discovery_source, device_cat))
ORDER BY 1, 2, 3;
Adobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
-- Content Discovery-to-Play Rate = session x title exposures followed by a user-initiated media.sessionStart of the same title within 30 minutes / all eligible session x title exposures x 100
-- Set before running: dataset table name (web_experience_events = your Web SDK experience event dataset table), query parameters $start_date and $end_date (YYYY-MM-DD, end at least 3 days ago) and $reporting_tz (for example America/New_York)
WITH events AS (
  -- All experience events: sessionization runs before any event-type filter so playback pings keep sessions alive
  SELECT
    identityMap['ECID'][0].id AS ecid,
    timestamp,
    eventType,
    environment.ipV4 AS ip_v4,
    environment.browserDetails.userAgent AS user_agent,
    device.type AS device_type,
    productListItems AS product_list_items,
    _tenant.discovery.discoverySource AS discovery_source,
    mediaTimed.primaryAssetReference._id AS media_asset_id,
    _tenant.playback.contentId AS play_content_id,
    _tenant.playback.contentSeriesId AS play_series_id,
    _tenant.playback.contentType AS play_content_type,
    _tenant.playback.playTrigger AS play_trigger
  FROM web_experience_events
  -- Two extra days on each side cover time zone shifts and plays that land after midnight
  WHERE timestamp >= to_timestamp(date_sub(to_date('$start_date'), 2))
    AND timestamp < to_timestamp(date_add(to_date('$end_date'), 3))
    AND identityMap['ECID'][0].id IS NOT NULL
),
sessionized AS (
  SELECT
    *,
    SESS_TIMEOUT(timestamp, 60 * 30) OVER (
      PARTITION BY ecid
      ORDER BY timestamp
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS session
  FROM events
),
scoped AS (
  SELECT
    *,
    CONCAT(ecid, '-', CAST(session.num AS STRING)) AS session_key
  FROM sessionized
  -- Internal traffic: same office IP ranges as the GA4 internal traffic rule (use your own office IP ranges)
  WHERE COALESCE(ip_v4, '') NOT LIKE '203.0.113.%'
    -- Known bots and crawlers: Web SDK experience events are not bot-filtered by default
    AND NOT (LOWER(COALESCE(user_agent, '')) RLIKE 'bot|crawl|spider|headless')
),
exposure_items AS (
  -- One row per title tile impression; episodes roll up to the parent series through eVar13 content_series_id
  SELECT
    s.session_key,
    s.timestamp AS event_ts,
    COALESCE(s.device_type, '(not set)') AS device_cat,
    LOWER(s.discovery_source) AS discovery_source,
    COALESCE(NULLIF(pli._experience.analytics.customDimensions.eVars.eVar13, ''), pli.SKU) AS title_key,
    LOWER(pli._experience.analytics.customDimensions.eVars.eVar14) AS entitlement_status
  FROM scoped AS s
  LATERAL VIEW explode(s.product_list_items) pli_table AS pli
  WHERE s.eventType = 'commerce.productListViews'
    -- Discovery surfaces only: hero billboard and continue-watching sources are outside this list
    AND LOWER(s.discovery_source) IN ('search', 'browse', 'recommendation', 'editorial')
    AND COALESCE(LOWER(pli._experience.analytics.customDimensions.eVars.eVar11), '') NOT IN ('trailer', 'preview')
    AND pli.SKU IS NOT NULL
),
exposures AS (
  -- Denominator grain: distinct session x title, timestamped at the first impression in the session
  SELECT
    session_key,
    title_key,
    MIN(event_ts) AS first_exposure_ts,
    min_by(device_cat, event_ts) AS device_cat,
    min_by(entitlement_status, event_ts) AS entitlement_status
  FROM exposure_items
  GROUP BY session_key, title_key
),
plays AS (
  -- User-initiated main-content starts only
  SELECT
    session_key,
    COALESCE(NULLIF(play_series_id, ''), NULLIF(play_content_id, ''), media_asset_id) AS title_key,
    timestamp AS play_ts
  FROM scoped
  WHERE eventType = 'media.sessionStart'
    AND COALESCE(LOWER(play_trigger), '') NOT IN ('autoplay', 'next_episode')
    AND COALESCE(LOWER(play_content_type), '') NOT IN ('trailer', 'preview')
    AND COALESCE(NULLIF(play_series_id, ''), NULLIF(play_content_id, ''), media_asset_id) IS NOT NULL
),
first_play AS (
  -- Numerator: first qualifying start of the same title in the same session, from the first exposure up to 30 minutes later; repeat starts count once
  SELECT
    e.session_key,
    e.title_key,
    MIN(p.play_ts) AS first_play_ts
  FROM exposures AS e
  JOIN plays AS p
    ON p.session_key = e.session_key
   AND p.title_key = e.title_key
   AND p.play_ts >= e.first_exposure_ts
   AND p.play_ts <= e.first_exposure_ts + INTERVAL 30 MINUTES
  GROUP BY e.session_key, e.title_key
),
title_level AS (
  SELECT
    e.session_key,
    e.title_key,
    to_date(from_utc_timestamp(e.first_exposure_ts, '$reporting_tz')) AS report_date,
    e.device_cat,
    e.entitlement_status,
    fp.first_play_ts
  FROM exposures AS e
  LEFT JOIN first_play AS fp
    ON fp.session_key = e.session_key
   AND fp.title_key = e.title_key
),
credited_source AS (
  -- Breakdown credit: the last discovery source that exposed the title at or before the converting play
  SELECT
    t.session_key,
    t.title_key,
    max_by(x.discovery_source, x.event_ts) AS credited_discovery_source
  FROM title_level AS t
  JOIN exposure_items AS x
    ON x.session_key = t.session_key
   AND x.title_key = t.title_key
  WHERE t.first_play_ts IS NOT NULL
    AND x.event_ts <= t.first_play_ts
  GROUP BY t.session_key, t.title_key
),
source_level AS (
  SELECT DISTINCT
    session_key,
    title_key,
    discovery_source
  FROM exposure_items
),
unified AS (
  -- Total rows: session x title grain
  SELECT
    report_date,
    'all_sources' AS discovery_source,
    device_cat,
    entitlement_status,
    CONCAT(session_key, '|', title_key) AS exposure_key,
    first_play_ts IS NOT NULL AS converted
  FROM title_level
  UNION ALL
  -- Breakdown rows: session x title x discovery source grain, so they do not sum to the total
  SELECT
    t.report_date,
    s.discovery_source,
    t.device_cat,
    t.entitlement_status,
    CONCAT(s.session_key, '|', s.title_key, '|', s.discovery_source) AS exposure_key,
    COALESCE(c.credited_discovery_source = s.discovery_source, false) AS converted
  FROM source_level AS s
  JOIN title_level AS t
    ON t.session_key = s.session_key
   AND t.title_key = s.title_key
  LEFT JOIN credited_source AS c
    ON c.session_key = s.session_key
   AND c.title_key = s.title_key
)
SELECT
  report_date,
  discovery_source,
  CASE WHEN grouping(device_cat) = 1 THEN 'all_devices' ELSE device_cat END AS device_category,
  COUNT(DISTINCT exposure_key) AS exposures,
  COUNT(DISTINCT CASE WHEN converted THEN exposure_key END) AS converted_exposures,
  ROUND(COUNT(DISTINCT CASE WHEN converted THEN exposure_key END) * 100.0 / NULLIF(COUNT(DISTINCT exposure_key), 0), 2) AS content_discovery_to_play_rate,
  COUNT(DISTINCT CASE WHEN entitlement_status = 'entitled' THEN exposure_key END) AS entitled_exposures,
  COUNT(DISTINCT CASE WHEN converted AND entitlement_status = 'entitled' THEN exposure_key END) AS entitled_converted_exposures,
  ROUND(COUNT(DISTINCT CASE WHEN converted AND entitlement_status = 'entitled' THEN exposure_key END) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN entitlement_status = 'entitled' THEN exposure_key END), 0), 2) AS entitled_discovery_to_play_rate
FROM unified
WHERE report_date BETWEEN to_date('$start_date') AND to_date('$end_date')
GROUP BY report_date, discovery_source, device_cat
  GROUPING SETS ((report_date, discovery_source), (report_date, discovery_source, device_cat))
ORDER BY 1, 2, 3;

Source Data

Digital Analytics (GA4)

Dependencies

Prerequisite: List the dependencies required for this Metric

Events:

view-item-listvideo-start

Dimensions:

session-idcontent-iddiscovery-sourceplay-triggerdevice-category

KPIs:

Report Attributes

Discovery Source Discovery Surface Rail Name Tile Position Content Type Genre Device Category App Platform App Version Country Subscription Tier Recommendation Model Variant New vs Returning User

Recommended Reporting Views

Views
Content Discovery PerformanceHome Experience PerformanceSearch and Recommendations PerformanceStreaming Engagement FunnelTitle Launch Scorecard

Segment Eligibility

true

Governance

Data SensitivityInternal

Derived KPIs

Governance

Created bydevyendarm
Created on9/5/2026
Last modified bydevyendarm
Last modified on9/14/2026
StatusPUBLISHED

GitHub

View related Pull Requestcatalog/metrics/content-discovery-to-play-rate.yml

Community Discussion