diff --git a/tutorials/signals-live-viewer-profiles/build-the-dashboard.md b/tutorials/signals-live-viewer-profiles/build-the-dashboard.md new file mode 100644 index 000000000..d429aa477 --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/build-the-dashboard.md @@ -0,0 +1,306 @@ +--- +title: "Build the dashboard" +position: 5 +sidebar_label: "Build the dashboard" +description: "Serve live viewer profiles to a React dashboard with a one-route Node.js back-end. The back-end registers sessions and retrieves session and video attributes in batch with the Signals Node.js SDK." +keywords: ["signals node sdk", "getBatchServiceAttributes", "live dashboard", "domain_sessionid", "profiles store"] +date: "2026-07-31" +--- + +In this section you'll build the dashboard: a page that lists every live viewer with their state, watch time, and skipped ads, and every video with its audience metrics, polling for fresh values every few seconds. + +There's one design problem to solve first. Signals is a lookup store: given an attribute key value such as a `domain_sessionid`, it returns that profile's attributes. It doesn't provide a way to enumerate all sessions that have profiles. The dashboard therefore needs to learn what to look up. The simplest solution is for the video page to announce itself: on load, it sends its `domain_sessionid` and the video's ID to a small back-end, which keeps the set of live sessions in memory. The dashboard asks that back-end for one profile row per registered session, and one audience row per video anyone is watching. + +The back-end has one route, with two methods: + +* `POST /api/viewers` registers a session ID and the video it's watching +* `GET /api/viewers` fetches attributes for all registered sessions and videos from Signals, using the [Node.js SDK](/docs/signals/connection/), and returns one row per viewer and one row per video + +In production you'd use whatever session registry you already have, for example your platform's concurrent-stream or heartbeat service, and add an expiry to the set. The in-memory version keeps this accelerator focused on the Signals integration. + +## Register the session from the video page + +Start with the video page, because everything else depends on knowing which sessions exist. The page needs to send its `domain_sessionid` and video ID to the back-end on load. The tracker exposes the session ID through the [`getDomainSessionId` method](/docs/sources/web-trackers/cookies-and-local-storage/getting-cookie-values/). + +In `src/VideoPage.jsx`, import the tracker at the top of the file: + +```javascript +import { tracker } from './tracker'; +``` + +Then add the registration call to the existing mount effect, after `startMediaTracking`, so the effect reads: + +```jsx + // Start a media tracking session when the page loads, and register this + // viewer's session with the dashboard back-end. + useEffect(() => { + const id = crypto.randomUUID(); + mediaIdRef.current = id; + startMediaTracking({ + id, + player: { label: videoId, mediaType: 'video' }, + pings: { pingInterval: 10 }, + }); + fetch('/api/viewers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: tracker.getDomainSessionId(), videoId }), + }); + return () => endMediaTracking({ id }); + }, []); +``` + +Nothing answers that route until you build the back-end. The video page tracks media events either way, but a viewer only reaches the dashboard once this registration call succeeds. + +## Create the back-end + +The back-end needs the same four connection values you used for the Python SDK. Add them to the app's `.env` file, below the Collector URL. Only variables prefixed with `VITE_` are exposed to the browser, so these stay server-side: + +```text +SIGNALS_API_URL=https://YOUR_ID.signals.snowplowanalytics.com +SIGNALS_API_KEY=your-api-key +SIGNALS_API_KEY_ID=your-api-key-id +SNOWPLOW_ORG_ID=your-organization-id +``` + +Create `server.js` in the project root: + +```javascript +import express from 'express'; +import { Signals } from '@snowplow/signals-node'; + +const signals = new Signals({ + baseUrl: process.env.SIGNALS_API_URL, + apiKey: process.env.SIGNALS_API_KEY, + apiKeyId: process.env.SIGNALS_API_KEY_ID, + organizationId: process.env.SNOWPLOW_ORG_ID, +}); + +// The sessions that have opened the video page, mapped to the video they opened. +const viewers = new Map(); + +const app = express(); +app.use(express.json()); + +// The video page calls this on load to make its session discoverable. +app.post('/api/viewers', (req, res) => { + const { sessionId, videoId } = req.body ?? {}; + if (typeof sessionId === 'string' && typeof videoId === 'string') { + viewers.set(sessionId, videoId); + } + res.status(204).end(); +}); + +// The dashboard polls this for one row per viewer and one row per video. +app.get('/api/viewers', async (req, res) => { + const sessionIds = [...viewers.keys()]; + const videoIds = [...new Set(viewers.values())]; + if (sessionIds.length === 0) { + res.json({ viewers: [], videos: [] }); + return; + } + try { + // Two calls, because a service covers exactly one attribute key. + const [profiles, audience] = await Promise.all([ + signals.getBatchServiceAttributes({ + name: 'viewer_profile_service', + attribute_key: 'domain_sessionid', + identifiers: sessionIds, + }), + signals.getBatchServiceAttributes({ + name: 'video_audience_service', + attribute_key: 'video_id', + identifiers: videoIds, + }), + ]); + // Each response is columnar: one array per attribute, in the same order as + // the identifiers that were sent. + res.json({ + viewers: sessionIds.map((sessionId, i) => ({ + sessionId, + videoId: viewers.get(sessionId), + viewerState: profiles.viewer_state?.[i] ?? null, + secondsWatched: profiles.seconds_watched?.[i] ?? null, + adsSkipped: profiles.ads_skipped?.[i] ?? null, + })), + videos: videoIds.map((videoId, i) => ({ + videoId, + activeViewers: audience.active_viewers?.[i] ?? null, + viewers: audience.viewers?.[i] ?? null, + adsSkipped: audience.total_ads_skipped?.[i] ?? null, + })), + }); + } catch (err) { + res.status(502).json({ error: String(err) }); + } +}); + +app.listen(3001, () => { + console.log('Live viewers backend listening on http://localhost:3001'); +}); +``` + +The interesting call is `getBatchServiceAttributes`, which fetches attributes for any number of identifiers in a single request. It returns an object with one key per attribute, each holding an array of values in the same order as the `identifiers` array. There's no identifier column in the response, so the rows are rebuilt from the order of the request. A session or video that hasn't produced any counted events yet returns `null` values, which the dashboard renders as a waiting state. See [retrieve attributes](/docs/signals/applications/retrieve-attributes/) for the full SDK reference. + +Both services are queried in parallel because a service covers a single attribute key: session profiles come from `viewer_profile_service` keyed on `domain_sessionid`, and audience metrics from `video_audience_service` keyed on your custom `video_id`. + +Proxy the app's `/api` requests to the back-end by updating `vite.config.js`: + +```javascript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3001', + }, + }, +}) +``` + +## Build the dashboard page + +Create `src/DashboardPage.jsx`. It polls the back-end every three seconds and renders two tables: the videos being watched, then the individual sessions, translating the raw `viewer_state` event names into labels: + +```jsx +import { useEffect, useState } from 'react'; + +const STATE_LABELS = { + play_event: 'Playing', + pause_event: 'Paused', + end_event: 'Finished', +}; + +export default function DashboardPage() { + const [data, setData] = useState({ viewers: [], videos: [] }); + const [error, setError] = useState(null); + + useEffect(() => { + let timeout; + async function poll() { + try { + const response = await fetch('/api/viewers'); + if (!response.ok) throw new Error(`Back-end returned ${response.status}`); + setData(await response.json()); + setError(null); + } catch (err) { + setError(String(err)); + } + timeout = setTimeout(poll, 3000); + } + poll(); + return () => clearTimeout(timeout); + }, []); + + return ( +
+

Live viewers

+ {error &&

{error}

} + {data.viewers.length === 0 && !error && ( +

No viewers yet. Open the video page in another tab and press play.

+ )} + {data.videos.length > 0 && ( + + + + + + + + + + + + {data.videos.map((video) => ( + + + + + + + ))} + +
By video, across every session
VideoWatchingViewersAds skipped
+ {video.videoId} + {video.activeViewers ?? 0}{video.viewers ?? 0}{video.adsSkipped ?? 0}
+ )} + {data.viewers.length > 0 && ( + + + + + + + + + + + + + {data.viewers.map((row) => ( + + + + + + + + ))} + +
By session
SessionVideoStateSeconds watchedAds skipped
+ {row.sessionId.slice(0, 8)} + + {row.videoId} + {STATE_LABELS[row.viewerState] ?? 'Waiting for events'}{Math.round(row.secondsWatched ?? 0)}{row.adsSkipped ?? 0}
+ )} +
+ ); +} +``` + +Replace `src/main.jsx` to route between the two pages based on the path: + +```jsx +import { createRoot } from 'react-dom/client'; +import './index.css'; +import './tracker'; +import VideoPage from './VideoPage.jsx'; +import DashboardPage from './DashboardPage.jsx'; + +const page = + window.location.pathname === '/dashboard' ? : ; + +createRoot(document.getElementById('root')).render(page); +``` + +## Run it end to end + +Start the back-end before the video page, so that the registration call has somewhere to go. Run the back-end and the dev server in two terminals. Node.js loads the `.env` file itself with the `--env-file` flag: + +```bash +node --env-file=.env server.js +``` + +```bash +npm run dev +``` + +Open `http://localhost:5173` and start the video, then open `http://localhost:5173/dashboard` in a second tab or window. Within a few seconds the dashboard shows your session's row. Skip an ad and watch `Ads skipped` increment, pause the video and watch the state flip to `Paused`, and leave it playing to see `Seconds watched` climb with each ping. + +To see the video-level attributes do their job, open the same video in a second browser, or in a private window, so that two different sessions are watching it. The session table gains a row, and the video's `Watching` count goes to two while both keep pinging. Opening `http://localhost:5173/?video=bunny-trailer` adds a second video row, aggregating only the sessions watching that title. + +![Live viewers dashboard with a by-video table showing sintel-trailer with 2 watching, 6 viewers, and 8 ads skipped, and bunny-trailer with 1 watching, 3 viewers, and 3 ads skipped, above a by-session table with three session rows](images/dashboard-live-viewers.png) + +The two counts per video differ on purpose. `Watching` comes from `active_viewers`, which only counts sessions that pinged in the last five minutes, so it tracks who's watching at this moment. `Viewers` comes from the lifetime `viewers` attribute, which counts every session that has ever played the video, so it keeps climbing as you test and is normally higher than the number of rows in the session table. + +## Troubleshooting + +If the dashboard doesn't show what you expect, work through these: + +* The dashboard reports an error from the back-end: check that the service names in `server.js` match the services you published, under **Signals** > **Services** in Console, and that the `attribute_key` values are `domain_sessionid` and the name you gave your custom key, `video_id` +* The back-end can't authenticate with Signals: check all four credential values in `.env`, and make sure you started the server with `--env-file=.env` +* The row shows `Waiting for events` and zeros: the session is registered but has no computed attributes yet. Interact with the video after the attribute groups have finished publishing, since earlier events aren't counted. +* The row disappears after a back-end restart: the in-memory set is empty again. Reload the video page to re-register. diff --git a/tutorials/signals-live-viewer-profiles/conclusion.md b/tutorials/signals-live-viewer-profiles/conclusion.md new file mode 100644 index 000000000..27becd17f --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/conclusion.md @@ -0,0 +1,34 @@ +--- +title: "Conclusion" +position: 6 +sidebar_label: "Conclusion" +description: "Review the infrastructure you didn't have to build for live viewer profiles, and extend the accelerator with push updates, engagement scoring, and ad performance attributes." +keywords: ["signals conclusion", "streaming infrastructure", "changed operator", "engagement scoring", "concurrent viewers"] +date: "2026-07-31" +--- + +You've built a live viewer profile system for a video streaming site. A React page tracks standard Snowplow media events, Signals computes each session's state, watch time, and skipped ads alongside per-video audience metrics, and a dashboard reads both through services with one batch call each. + +The same outcome built by hand means deploying something to forward the event stream, running stream processing to fold events into viewer state, provisioning a low-latency store for the profiles, and maintaining a service to serve them, which is what the [Kafka accelerator](/tutorials/kafka-live-viewer-profiles/introduction) walks through. With Signals, the state computation, storage, and serving are managed inside your Snowplow pipeline, and your code shrinks to the two things that are genuinely yours: the tracking and the UI. + +Switching from session-level to video-level metrics was a custom attribute key and a second attribute group, with no change to the tracking and no new schema. Any property your events already carry can become an aggregation key that way. + +## Extend the accelerator + +Some directions to take this further: + +* Push instead of polling: the dashboard polls every three seconds. Signals [interventions](/docs/signals/interventions/) support a [`changed` operator](/docs/signals/interventions/#the-changed-operator) that fires every time an attribute's value changes, which is a natural fit for pushing `viewer_state` transitions to the dashboard the moment a viewer pauses. Note that the Node.js SDK doesn't support intervention subscriptions: subscribe with the Python SDK, the browser plugin, or the Signals API, as described in [subscribe to interventions](/docs/signals/applications/subscribe/). The [interventions tutorial](/tutorials/signals-interventions/start) walks through the full workflow. +* Engagement scoring: add attributes such as a `seek_count` counter or a `mean` of playback rate, and combine them into an engagement score for each session in your back-end +* Trending titles: the `video_audience` group already gives you a live count per video. Add a shorter `period` for a "watching in the last minute" figure, and sort the dashboard's video table by it to get a trending list. +* Ad performance: the media schemas include ad quartile, click, pause, and resume events. Attributes over those events give you per-session or per-video ad engagement, for example a `category_count` of ad event names. +* Viewer-level profiles: this accelerator keys sessions on `domain_sessionid`. Adding a third attribute group keyed on `domain_userid` gives you profiles that persist across sessions on the same device. + +## Next steps + +Continue with these related resources: + +* [Set up Signals for real-time calculation](/tutorials/signals-quickstart/start) is the quick start for the wider Signals workflow +* [Trigger real-time actions with interventions](/tutorials/signals-interventions/start) covers the push-based side of Signals +* [Attribute keys](/docs/signals/attributes/attribute-keys/) documents the built-in keys and how to define your own +* [The Snowplow MCP server](/docs/llms-support/snowplow-mcp/) lets an AI assistant create Signals definitions like these from a prompt +* [Live viewer profiles with Kafka](/tutorials/kafka-live-viewer-profiles/introduction) is the self-managed alternative to this accelerator diff --git a/tutorials/signals-live-viewer-profiles/define-viewer-attributes.md b/tutorials/signals-live-viewer-profiles/define-viewer-attributes.md new file mode 100644 index 000000000..494f1a3e4 --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/define-viewer-attributes.md @@ -0,0 +1,313 @@ +--- +title: "Define the viewer attributes" +position: 3 +sidebar_label: "Define viewer attributes" +description: "Define two Signals stream attribute groups from Snowplow media events, using an AI assistant or the Python SDK. One group holds session-level viewer state, watch time, and skipped ads, and the other holds video-level audience metrics on a custom attribute key." +keywords: ["stream attribute group", "domain_sessionid", "custom attribute key", "media events", "signals python sdk", "snowplow mcp", "viewer state"] +date: "2026-07-31" +--- + +With media events flowing, you can tell Signals what to compute from them. In this section you'll define two [attribute groups](/docs/signals/attributes/attribute-groups/): + +* `viewer_profile`, keyed on the built-in `domain_sessionid` [attribute key](/docs/signals/attributes/attribute-keys/), so that each viewing session gets its own profile +* `video_audience`, keyed on a custom `video_id` attribute key, so that every session watching the same video updates one shared set of counters + +You'll also define a [service](/docs/signals/applications/services/) for each group, and publish the whole configuration. + +## Map viewer actions to session attributes + +The `viewer_profile` group computes three [attributes](/docs/signals/attributes/attributes/): + +| Attribute | Calculated from | Aggregation | Property | +| ----------------- | ---------------------------------------- | ----------- | ----------------------------------------- | +| `viewer_state` | `play_event`, `pause_event`, `end_event` | `last` | `event_name` atomic property | +| `seconds_watched` | `ping_event`, `pause_event`, `end_event` | `last` | `timePlayed` in the media `session` entity | +| `ads_skipped` | `ad_skip_event` | `counter` | None | + +Two details of the [media schemas](/docs/events/ootb-data/media-events/) shape this design: + +* Each media action is its own event schema under the `com.snowplowanalytics.snowplow.media` vendor, and the playback state events (`play_event`, `pause_event`, `end_event`) have no properties of their own. The most recent event name is therefore the viewer's state: `viewer_state` takes the `last` value of the `event_name` atomic property across those three event types. +* The media `session` entity, attached to every media event, already accumulates playback statistics. Its `timePlayed` property is the total seconds of content played so far, so `seconds_watched` takes the `last` value of `timePlayed` rather than summing anything. Ping events refresh it every 10 seconds during playback, and pause and end events capture the final value when playback stops. + +`timePlayed` counts playback within one media session, which starts when the page calls `startMediaTracking`. Reloading the video page starts a new media session, so `seconds_watched` restarts from zero, while `ads_skipped` keeps counting because it's a counter over the whole `domain_sessionid`. Watching the same video twice without reloading does add up, because that stays within one media session. + +`ads_skipped` is a plain counter: it increments every time Signals processes an `ad_skip_event` for the session. All three attributes use the `Lifetime` period, so they cover the whole session rather than a rolling time window. + +## Aggregate metrics per video + +The session profiles answer "What is this viewer doing?" A dashboard for the whole catalog also needs the opposite view: "How many people are watching this title, across every session?" Signals answers that with a second attribute group keyed on the video rather than the session. + +Attribute keys aren't limited to the built-in user, device, and session identifiers. A [custom attribute key](/docs/signals/attributes/attribute-keys/) points at any property in your events, and Signals aggregates against whatever value that property holds. The standard `media_player` entity has no field for the content itself, so the video page sets its `label` to the video's ID, and the `video_id` attribute key reads that. + +Because the `video_audience` group aggregates across sessions, `domain_sessionid` becomes something to count rather than something to group by, and the group computes three attributes. + +`active_viewers` and `viewers` both use `approx_count_distinct` on the `domain_sessionid` atomic property, which counts how many different sessions produced the events. The difference is the window: `active_viewers` has a five-minute `period`, so it only counts sessions that pinged recently, which is a reasonable stand-in for concurrent viewers when the page pings every 10 seconds. `viewers` has no `period`, so it counts every session that has ever played the video. `total_ads_skipped` is the same counter as the session-level `ads_skipped`, but summed over everyone watching. + +`approx_count_distinct` uses [HyperLogLog](https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/) internally, so at high cardinality it's a close approximation rather than an exact count. At the handful of viewers in this accelerator it's exact. + +That's the whole design. Both routes below produce the same configuration, so pick one: [describe it to an AI assistant](#define-using-the-ai-assistant), or [write it with the Signals Python SDK](#define-using-the-python-sdk). + +## Define using the AI assistant + +The design above is a description of what to compute, and an AI assistant with access to your Signals registry can work from the description rather than from code. Two ways to get one: + +* Any MCP-capable assistant, such as Claude Code or Cursor, connected to the [Snowplow MCP server](/docs/llms-support/snowplow-mcp/), which exposes the Signals registry as tools alongside the rest of your Snowplow account +* The [Snowplow Assistant](/docs/llms-support/console-agent/), if your organization has it enabled in [Snowplow Console](https://console.snowplowanalytics.com): it covers the same Signals capabilities with nothing to set up + +Paste this prompt into the assistant. It asks for the whole configuration: the custom attribute key, both attribute groups, and both services. + +```text +Using the Snowplow tools available to you, set up Snowplow Signals for a live +viewer profiles dashboard, computed from the standard Snowplow media events. +Create everything as drafts and don't publish anything yet. + +1. An attribute key called video_id that reads the label property of the + media_player entity (vendor com.snowplowanalytics.snowplow, major + version 2). The video page puts the video's ID in that label. + +2. A stream attribute group called viewer_profile, keyed on the built-in + domain_sessionid attribute key, with three attributes over the + com.snowplowanalytics.snowplow.media events at version 1-0-0: + - viewer_state (string): the last event_name across play_event, + pause_event, and end_event, so the most recent playback state wins + - seconds_watched (double): the last value of timePlayed from the media + session entity (com.snowplowanalytics.snowplow.media session, major + version 1), across ping_event, pause_event, and end_event. timePlayed + is already a running total, so read its most recent value, don't sum it + - ads_skipped (int32): a counter of ad_skip_event, default 0 + All three cover the whole session, so none of them has a period. + +3. A stream attribute group called video_audience, keyed on video_id, with: + - active_viewers (int32): approximate distinct count of domain_sessionid + over ping_event, within a five-minute period, default 0 + - viewers (int32): approximate distinct count of domain_sessionid over + play_event and ping_event, with no period, default 0 + - total_ads_skipped (int32): a counter of ad_skip_event, default 0 + +4. One service per group: viewer_profile_service for viewer_profile, and + video_audience_service for video_audience. A service can only reference + groups that share an attribute key, so these two can't be combined. +``` + +:::note[Review before you publish] +Signals saves new definitions as drafts. Nothing reaches the streaming engine, and nothing is calculated, until you publish, which is your safety net for a configuration you didn't write yourself. Ask the assistant to show you the full definition of each group, or open **Signals** > **Attribute groups** in Console, and check the aggregations, properties, and periods against this page before you tell it to publish. +::: + +Once the definitions match this page, tell the assistant to publish them. You can also publish the drafts yourself from **Signals** > **Attribute groups** in Snowplow Console. + +## Define using the Python SDK + +This route builds the same configuration as a script, with the [Signals Python SDK](/docs/signals/connection/). + +### Connect to Signals + +Install the Signals Python SDK into your Python environment: + +```bash +pip install snowplow-signals +``` + +You'll need four connection values, all reachable from the **Signals** > **Overview** page in Snowplow Console: the Signals API URL and your organization ID are displayed there, and you can generate the API key and key ID in Console under [account management](/docs/account-management/). Export them as environment variables, using your own values: + +```bash +export SIGNALS_API_URL=https://YOUR_ID.signals.snowplowanalytics.com +export SIGNALS_API_KEY=your-api-key +export SIGNALS_API_KEY_ID=your-api-key-id +export SNOWPLOW_ORG_ID=your-organization-id +``` + +Create a script called `define_attributes.py`, starting with the imports and the connection: + +```python +import os +from datetime import timedelta + +from snowplow_signals import ( + Attribute, + AtomicProperty, + AttributeKey, + EntityProperty, + Event, + Service, + Signals, + StreamAttributeGroup, + domain_sessionid, +) + +sp_signals = Signals( + api_url=os.environ["SIGNALS_API_URL"], + api_key=os.environ["SIGNALS_API_KEY"], + api_key_id=os.environ["SIGNALS_API_KEY_ID"], + org_id=os.environ["SNOWPLOW_ORG_ID"], +) + +OWNER = "you@example.com" # replace with your email address +``` + +### Define the session attributes + +Each `Event` object references a media schema by vendor, name, and version, exactly as the schema URIs appear in the Inspector. Defining them once keeps the attributes readable, because several attributes read the same events: + +```python +MEDIA_VENDOR = "com.snowplowanalytics.snowplow.media" + +play = Event(vendor=MEDIA_VENDOR, name="play_event", version="1-0-0") +pause = Event(vendor=MEDIA_VENDOR, name="pause_event", version="1-0-0") +end = Event(vendor=MEDIA_VENDOR, name="end_event", version="1-0-0") +ping = Event(vendor=MEDIA_VENDOR, name="ping_event", version="1-0-0") +ad_skip = Event(vendor=MEDIA_VENDOR, name="ad_skip_event", version="1-0-0") +``` + +Group the three session attributes into a `StreamAttributeGroup`, keyed on the built-in `domain_sessionid` attribute key. None of the attributes set a `period`, so they all default to `Lifetime`: + +```python +viewer_profile = StreamAttributeGroup( + name="viewer_profile", + version=1, + attribute_key=domain_sessionid, + owner=OWNER, + description="Live playback state for each viewing session", + attributes=[ + Attribute( + name="viewer_state", + description="The most recent playback state event in this session", + type="string", + events=[play, pause, end], + aggregation="last", + property=AtomicProperty(name="event_name"), + ), + Attribute( + name="seconds_watched", + description="Total seconds of content played in this media session", + type="double", + events=[ping, pause, end], + aggregation="last", + property=EntityProperty( + vendor=MEDIA_VENDOR, + name="session", + major_version=1, + path="timePlayed", + ), + ), + Attribute( + name="ads_skipped", + description="Number of ads skipped in this session", + type="int32", + events=[ad_skip], + aggregation="counter", + default_value=0, + ), + ], +) +``` + +### Define the video attributes + +The custom `video_id` attribute key comes first, because the attribute group references it: + +```python +video_id = AttributeKey( + name="video_id", + description="The video being watched, read from the media player label", + property=EntityProperty( + vendor="com.snowplowanalytics.snowplow", + name="media_player", + major_version=2, + path="label", + ), +) +``` + +Then the group keyed on it: + +```python +video_audience = StreamAttributeGroup( + name="video_audience", + version=1, + attribute_key=video_id, + owner=OWNER, + description="Audience metrics for each video, across all sessions", + attributes=[ + Attribute( + name="active_viewers", + description="Sessions that sent a playback ping in the last five minutes", + type="int32", + events=[ping], + aggregation="approx_count_distinct", + property=AtomicProperty(name="domain_sessionid"), + period=timedelta(minutes=5), + default_value=0, + ), + Attribute( + name="viewers", + description="Distinct sessions that have played this video", + type="int32", + events=[play, ping], + aggregation="approx_count_distinct", + property=AtomicProperty(name="domain_sessionid"), + default_value=0, + ), + Attribute( + name="total_ads_skipped", + description="Ads skipped on this video across all sessions", + type="int32", + events=[ad_skip], + aggregation="counter", + default_value=0, + ), + ], +) +``` + +### Publish the definitions + +Retrieving attributes through a service is the recommended pattern for applications, because the service name stays stable while you iterate on attribute group versions. Pass the group objects straight to `Service`, and the SDK records the group name and version for you: + +```python +viewer_profile_service = Service( + name="viewer_profile_service", + owner=OWNER, + description="Session-level viewer profiles for the dashboard", + attribute_groups=[viewer_profile], +) + +video_audience_service = Service( + name="video_audience_service", + owner=OWNER, + description="Video-level audience metrics for the dashboard", + attribute_groups=[video_audience], +) +``` + +A service bundles attribute groups that share an attribute key, so each of these two groups needs its own service, and the dashboard back-end makes one call per service. + +Nothing exists in Signals until you publish. Include the custom attribute key in the same list, and put it before the group that uses it: an attribute group can only be published once its key exists. + +```python +sp_signals.publish( + [ + video_id, + viewer_profile, + video_audience, + viewer_profile_service, + video_audience_service, + ] +) +print("Published 1 attribute key, 2 attribute groups, and 2 services") +``` + +Run the script: + +```bash +python define_attributes.py +``` + +Publishing isn't instant: give the definitions a moment to reach the streaming engine. Signals only computes attributes from events processed after that point, so events you tracked before publishing don't contribute. + +Open **Signals** > **Attribute groups** in Snowplow Console and select `viewer_profile` to review what you published: + +![The published viewer_profile attribute group in Console showing the ads_skipped, seconds_watched, and viewer_state attributes with their events, properties, and Lifetime periods](images/console-attribute-group-published.png) + +Your new `video_id` key appears under **Attribute keys**, alongside the four built-in keys, and both services appear under **Services**. + +A published attribute group version is immutable, so running the script again as it stands won't change anything. To change a definition, increment `version` and publish again. diff --git a/tutorials/signals-live-viewer-profiles/images/console-attribute-group-published.png b/tutorials/signals-live-viewer-profiles/images/console-attribute-group-published.png new file mode 100644 index 000000000..28b6cb3fc Binary files /dev/null and b/tutorials/signals-live-viewer-profiles/images/console-attribute-group-published.png differ diff --git a/tutorials/signals-live-viewer-profiles/images/dashboard-live-viewers.png b/tutorials/signals-live-viewer-profiles/images/dashboard-live-viewers.png new file mode 100644 index 000000000..715973234 Binary files /dev/null and b/tutorials/signals-live-viewer-profiles/images/dashboard-live-viewers.png differ diff --git a/tutorials/signals-live-viewer-profiles/images/viewer-and-dashboard-split-screen.png b/tutorials/signals-live-viewer-profiles/images/viewer-and-dashboard-split-screen.png new file mode 100644 index 000000000..35492ace9 Binary files /dev/null and b/tutorials/signals-live-viewer-profiles/images/viewer-and-dashboard-split-screen.png differ diff --git a/tutorials/signals-live-viewer-profiles/introduction.md b/tutorials/signals-live-viewer-profiles/introduction.md new file mode 100644 index 000000000..0225a0d27 --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/introduction.md @@ -0,0 +1,71 @@ +--- +title: "Learn how to create live viewer profiles using Signals" +position: 1 +sidebar_label: "Introduction" +description: "Build a real-time viewer profile dashboard for a video streaming site with Snowplow media tracking and Signals. Attributes are computed per session and per video, with no stream processing to operate." +keywords: ["snowplow signals", "live viewer profiles", "media tracking", "real-time video analytics", "custom attribute key"] +date: "2026-07-31" +--- + +In this solution accelerator you'll build live viewer profiles for a video streaming site: a dashboard that shows who's watching right now, whether they're playing or paused, how many seconds they've watched, and how many ads they've skipped. It also aggregates the same events per video, so you can see how many people are watching each title across every session. + +[Snowplow Signals](/docs/signals/introduction/) computes the profiles inside your Snowplow pipeline, from the media events your trackers already send. There's no stream processing to write and no profile store to operate, so the only code you write is the tracking page and a thin dashboard. If you'd rather own the stream processing yourself, the [live viewer profiles with Kafka accelerator](/tutorials/kafka-live-viewer-profiles/introduction) builds the same dashboard on self-managed infrastructure. + +On the left side of the image below, a viewer is watching a video with Snowplow media tracking. On the right, the dashboard lists their session with its live state, watch time, and skipped ad count, alongside a row for each video, all served from the Profiles Store. + +![Split screen with a video page playing the Sintel trailer on the left, and the live viewers dashboard on the right showing two video rows and three session rows with their state, watch time, and skipped ad counts](images/viewer-and-dashboard-split-screen.png) + +## Architecture + +The video page sends media events to your Snowplow pipeline like any other tracked application. Signals reads the enriched stream, folds the events into attributes, and serves them from the Profiles Store. Your dashboard back-end reads those attributes over HTTPS: + +```mermaid +flowchart LR + page["Video page
browser tracker
and media plugin"] + collector["Collector"] + enrich["Enrich"] + engine["Signals
streaming engine"] + store["Profiles Store"] + backend["Back-end
Signals Node.js SDK"] + dashboard["Dashboard"] + + page --> collector --> enrich --> engine --> store + store --> backend --> dashboard + page -. "registers its session" .-> backend +``` + +Everything between the Collector and the Profiles Store is managed by Snowplow. You build the boxes at either end: the video page and the dashboard, joined by a back-end that does little more than pass identifiers to Signals and hand the attributes back. + +You'll build them in this order: + +1. A React video page that tracks play, pause, seek, ping, and ad events with the Snowplow [media tracking plugin](/docs/sources/web-trackers/tracking-events/media/snowplow/) +2. Two Signals [attribute groups](/docs/signals/attributes/attribute-groups/): one keyed on the session, for each viewer's state, watch time, and skipped ads, and one keyed on a custom [attribute key](/docs/signals/attributes/attribute-keys/) for the video, for audience metrics across sessions. Define them with the [Signals Python SDK](/docs/signals/connection/), or from a single prompt with an AI assistant +3. A dashboard page backed by a one-route Node.js server that reads both groups with the [Signals Node.js SDK](/docs/signals/connection/) + +Every file you need is listed in full in these pages, so there's nothing to clone and nothing to download. This accelerator should take around one hour to complete. + +### Snowplow implementation + +The demo tracks standard Snowplow [media events](/docs/events/ootb-data/media-events/) and computes everything from them, so there are no custom schemas to design: + +* The video page sends `play_event`, `pause_event`, `end_event`, `seek_start_event`, and `seek_end_event` as the viewer controls playback, a `ping_event` every 10 seconds while the video plays, and `ad_break_start_event`, `ad_start_event`, and `ad_skip_event` or `ad_complete_event` for a simulated pre-roll +* Every one of those events carries two entities: `media_player`, whose `label` holds the video's ID, and the media `session` entity, whose `timePlayed` is a running total of seconds played +* The `viewer_profile` attribute group folds the playback events into each session's state, watch time, and skipped ads, keyed on the built-in `domain_sessionid` attribute key +* The `video_audience` attribute group aggregates the same events per video, keyed on a custom attribute key that reads the `media_player` label, which is what turns per-session tracking into per-video metrics without any new tracking code +* A [service](/docs/signals/applications/services/) for each group gives the back-end one stable name to read, so you can iterate on attribute group versions without changing the dashboard + +## Prerequisites + +This accelerator assumes that you have: + +* A Snowplow pipeline with a [Collector endpoint](/docs/sources/) you can send events to, because Signals computes attributes from your live event stream +* [Signals enabled](/docs/signals/setup/) on your Snowplow account, since the viewer profiles depend on it +* Node.js 20.6 or later, to run the demo app and its back-end +* Python 3.9 or later, to define the Signals configuration with the [Signals Python SDK](/docs/signals/connection/), unless you define it with an AI assistant instead +* Basic familiarity with React, and with [Snowplow events](/docs/fundamentals/events/) and [entities](/docs/fundamentals/entities/) + +:::note[A Snowplow account is required] +Signals computes attributes from real events flowing through your pipeline, so you'll need a Snowplow account with a pipeline you can send events to, and Signals enabled on it. + +If you don't have one, you can deploy and use a [Snowplow free trial](https://snowplow.io/get-started/snowplow-free-trial) to follow along. +::: diff --git a/tutorials/signals-live-viewer-profiles/meta.json b/tutorials/signals-live-viewer-profiles/meta.json new file mode 100644 index 000000000..103d1f7a7 --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/meta.json @@ -0,0 +1,8 @@ +{ + "title": "Create live viewer profiles using Signals", + "description": "Build a real-time viewer profile dashboard for a video streaming site with Snowplow media tracking and Signals, with no streaming infrastructure to manage.", + "label": "Solution accelerator", + "useCase": "Real-time personalization", + "technologies": ["React", "Node.js", "Python"], + "snowplowTech": ["Signals"] +} diff --git a/tutorials/signals-live-viewer-profiles/set-up-media-tracking.md b/tutorials/signals-live-viewer-profiles/set-up-media-tracking.md new file mode 100644 index 000000000..83ca223b0 --- /dev/null +++ b/tutorials/signals-live-viewer-profiles/set-up-media-tracking.md @@ -0,0 +1,329 @@ +--- +title: "Set up media tracking" +position: 2 +sidebar_label: "Set up media tracking" +description: "Build a React video page that tracks play, pause, seek, ping, and ad events with the Snowplow browser tracker and media plugin, and verify the events in Snowplow Inspector." +keywords: ["media tracking plugin", "snowplow browser tracker", "react video player", "ad events", "media session"] +date: "2026-07-31" +--- + +In this section you'll build the video page: a React app that plays a trailer and tracks how the user interacts with it. The [Snowplow media plugin](/docs/sources/web-trackers/tracking-events/media/snowplow/) sends a [self-describing event](/docs/fundamentals/events/#self-describing-events) for each playback action, along with entities describing the player state and the media session. Signals will compute the viewer profiles from these events in the next section. + +The page also simulates a pre-roll ad break with a **Skip ad** button. Real streaming sites track ad events from their ad framework, and the skip button gives you a hands-on way to generate `ad_skip_event` events for the `ads_skipped` attribute. + +## Create the app + +Scaffold a React app with Vite, and install the Snowplow browser tracker and media plugin. The other two packages are for the dashboard back-end, which you'll build later. + +```bash +npm create vite@latest signals-live-viewer -- --template react +cd signals-live-viewer +npm install +npm install @snowplow/browser-tracker @snowplow/browser-plugin-media @snowplow/signals-node express +``` + +The scaffold includes example components that you won't need. Delete `src/App.jsx`, `src/App.css`, and the `src/assets` directory. + +Create a `.env` file in the project root with your Collector endpoint. Vite exposes environment variables prefixed with `VITE_` to the browser code. You'll add the Signals credentials to this same file later. + +```text +VITE_COLLECTOR_URL=https://collector.example.com +``` + +## Initialize the tracker + +Create `src/tracker.js` to initialize the [browser tracker](/docs/sources/web-trackers/) with the media plugin: + +```javascript +import { newTracker } from '@snowplow/browser-tracker'; +import { SnowplowMediaPlugin } from '@snowplow/browser-plugin-media'; + +export const tracker = newTracker('sp1', import.meta.env.VITE_COLLECTOR_URL, { + appId: 'signals-live-viewer', + plugins: [SnowplowMediaPlugin()], +}); +``` + +## Build the video page + +Create `src/VideoPage.jsx`. It renders an HTML5 `