> ## Documentation Index
> Fetch the complete documentation index at: https://subtext.fullstory.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Snowplow and Subtext: From a Signal to the Session

> Attach the Subtext session URL to every Snowplow event as a custom entity, then jump from any modeled behavioral signal — a funnel drop, a failed-events spike, an odd custom event — straight into the user's real session.

Snowplow tells you *that* something moved in your behavioral data: a step-completion rate slips below trend, an event fires at an implausible rate, a batch of events fails schema validation. It can't show you what one specific person saw. Subtext can. Attach the Subtext session URL to every Snowplow event as a custom entity and it flows through the pipeline into your warehouse, so any event you model there deep-links back to the matching session — your agent reads the `subtext_url`, opens that person's real session, and walks the decisive moments with screenshots, the component tree, network, and console. Snowplow detects; Subtext diagnoses.

<Note>
  This guide assumes the [Subtext capture snippet](/docs/install/overview) is installed and capturing sessions, and a Snowplow web tracker is running — either the tag (`window.snowplow(...)`) or the npm `@snowplow/browser-tracker`. The examples query ClickHouse — which Snowplow feeds via its Snowbridge stream forwarder or via the Lake Loader's Iceberg/Delta output, not a dedicated loader — but any of Snowplow's supported loader targets (Snowflake, Databricks, BigQuery, Redshift) works the same way; only the entity-column syntax differs.
</Note>

## Attach the session URL

Snowplow attaches extra data to events as **entities** (custom contexts), each validated against a schema. The cleanest way to add one value to *every* event is a **global context** — define it once and the tracker sends it with every subsequent event, no per-call wiring.

### Define the entity schema

Entities are schema-validated, so first add a self-describing schema to your Iglu registry. A minimal one holds a single URL:

```json theme={null}
{
  "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
  "self": {
    "vendor": "com.yourco",
    "name": "subtext_session",
    "format": "jsonschema",
    "version": "1-0-0"
  },
  "type": "object",
  "properties": { "url": { "type": "string", "maxLength": 4096 } },
  "required": ["url"],
  "additionalProperties": false
}
```

### Attach it as a dynamic global context

The session URL changes across sessions and carries a per-moment timestamp, so use a **context generator** (a function) rather than a static object. The tracker runs it for every event, reading the URL fresh, and skips the entity when there is no session yet by returning `undefined`. Register it once, after the tracker is initialized — every `trackPageView`, `trackSelfDescribingEvent`, and built-in event fired afterward carries the entity.

<CodeGroup>
  ```js @snowplow/browser-tracker theme={null}
  import { addGlobalContexts } from '@snowplow/browser-tracker'

  addGlobalContexts([
    () => {
      const url = FS('getSession', { format: 'url.now' })
      if (!url) return undefined // no session yet — attach nothing
      return {
        schema: 'iglu:com.yourco/subtext_session/jsonschema/1-0-0',
        data: { url },
      }
    },
  ])
  ```

  ```js Tag (window.snowplow) theme={null}
  window.snowplow('addGlobalContexts', [
    function () {
      var url = FS('getSession', { format: 'url.now' })
      if (!url) return undefined
      return { schema: 'iglu:com.yourco/subtext_session/jsonschema/1-0-0', data: { url: url } }
    },
  ])
  ```
</CodeGroup>

### Where it lands in the warehouse

Snowplow shreds each entity into its own column or table named from the schema — for the schema above, a column like `contexts_com_yourco_subtext_session_1`, an array whose first element holds `{ "url": "..." }`. Query the URL out of that path (ClickHouse example):

```sql theme={null}
SELECT
    user_id,
    contexts_com_yourco_subtext_session_1[1].url AS subtext_url,
    event_name,
    collector_tstamp
FROM atomic.events
WHERE event_name = 'checkout_error'
  AND length(contexts_com_yourco_subtext_session_1) > 0
ORDER BY collector_tstamp DESC
LIMIT 20;
```

The exact column path depends on your loader; check your `atomic` schema for the shredded entity name.

<Warning>
  Don't read `FS('getSession')` at module top level — it returns `null` until the session has started; the generator runs per event, which is exactly when a session exists. Don't attach the URL as a *static* global context object, which would freeze the value at registration time — use the generator so each event gets the current, moment-linked URL. Don't skip the schema step — an entity that fails validation lands in your failed events stream, not your warehouse. And don't put the signed URL anywhere untrusted readers can reach it; it grants replay access.
</Warning>

## From signal to session

However you first hear about a problem in Snowplow — a modeled behavioral table, a Failed Events alert, a data-quality check — the path is the same: get to the `subtext_url` on the affected rows, then hand it to your agent for a Detect-vs-Diagnose read. The atomic events say a user errored or dropped; the session shows what they actually experienced.

<AccordionGroup>
  <Accordion title="A behavioral model flags a friction point">
    An analytics engineer or PM reviewing a dbt-modeled funnel built on Snowplow atomic events notices a step-completion rate falling below trend for a specific event.

    Query the modeled table (or `atomic.events`) for the affected users' sessions and read `subtext_url` off the shredded entity column:

    ```sql theme={null}
    SELECT user_id, contexts_com_yourco_subtext_session_1[1].url AS subtext_url, collector_tstamp
    FROM atomic.events
    WHERE event_name = 'checkout_step_completed'
      AND length(contexts_com_yourco_subtext_session_1) > 0
      AND user_id IN (<dropped users>)
    ORDER BY collector_tstamp DESC
    LIMIT 20;
    ```

    Grab a few, then hand them to your agent:

    ```text theme={null}
    Our Snowplow-modeled checkout funnel shows a step converting below trend. Here are session URLs for
    users who dropped at that step:
    <PASTE 3-4 subtext_urls>

    Review them and find the common failure point. Detect-vs-Diagnose: the data says they dropped — what
    does each session actually show at that step? One synthesized hypothesis at the end.
    ```
  </Accordion>

  <Accordion title="A Failed Events alert">
    The data engineer who owns tracking quality sees Snowplow's Failed Events stream spike — events failing schema validation, which usually means a broken client, not just bad instrumentation.

    Successful events from the same users still carry the entity, so find a recent good event for an affected user and read its `subtext_url`.

    ```text theme={null}
    Snowplow Failed Events spiked for `checkout_step_completed` from the web client. Here's a session
    from an affected user around that time: <PASTE subtext_url>

    Open it and tell me whether the client is genuinely broken (bad state, JS error) or the payload is
    just mis-shaped. Show me the UI and console at that moment so I can tell a real bug from a schema drift.
    ```
  </Accordion>

  <Accordion title="A self-describing event looks wrong">
    A product analyst investigating a custom event finds a self-describing event firing at an implausible rate or with odd properties.

    Filter `atomic.events` to that event schema and pull the session link.

    ```text theme={null}
    This custom event is firing far more than expected. Here's a session where it happened:
    <PASTE subtext_url>

    Open it around that event. Is the user really doing this, or is the client double-firing? Walk me
    through the relevant moments.
    ```
  </Accordion>
</AccordionGroup>

<Tip>
  For a funnel investigation across several dropped users, ask your agent to consolidate the findings into one proof document (`doc-create`) — a screenshot per session and a single shareable link — then drop it into the model's documentation or the data-quality ticket so the next person sees the human story behind the number.
</Tip>

## For agents

An autonomous agent can run the whole loop, but not against Snowplow directly. Snowplow is a pipeline, not a query product: Snowplow's official MCP server works against the Snowplow Console — tracking plans, pipeline health, and failed-event investigation — not your event data, and querying the collector or enrichment stack is not the path. Snowplow's value is that it lands clean, modeled events in your **warehouse**, so the agent story runs through the warehouse's MCP.

* If that warehouse is ClickHouse, use the official **`mcp-clickhouse`** server (`list_databases`, `list_tables`, `run_query`, read-only by default).
* For Snowflake, BigQuery, or Redshift, use that warehouse's own MCP or SQL API. The query shape is identical; only the entity-column syntax differs.

<Steps>
  <Step title="Discover and extract in one query">
    The shredded entity column returns both the friction and the link on the same rows, so a single query does discovery and extraction together:

    ```sql theme={null}
    -- worst-converting checkout step in the last week, with a session per dropped user
    SELECT
        user_id,
        contexts_com_yourco_subtext_session_1[1].url AS subtext_url,
        collector_tstamp
    FROM atomic.events
    WHERE event_name = 'checkout_step_completed'
      AND collector_tstamp > now() - INTERVAL 7 DAY
      AND length(contexts_com_yourco_subtext_session_1) > 0
    ORDER BY collector_tstamp DESC
    LIMIT 25;
    ```
  </Step>

  <Step title="Hand off to Subtext">
    For each URL, call `review-open(session_url=<subtext_url>)` on the Subtext MCP and get a Detect-vs-Diagnose read: the model says they dropped versus what the session actually shows.
  </Step>
</Steps>

<Note>
  The entity only round-trips if the tracker attached it at capture (see [Attach the session URL](#attach-the-session-url)); events fired before you registered the global context carry an empty array, so filter `length(...) > 0`. The shredded column name is derived from your schema's vendor, name, and version — confirm it against your `atomic` schema before hard-coding it. Keep the warehouse MCP read-only; a review agent never needs write access.
</Note>

## Related

* [Session Review overview](/docs/session-review/overview) — what your agent does once a session is open.
* [Install the capture snippet](/docs/install/overview) — required before any session URL exists to attach.
