diff --git a/.specify/feature.json b/.specify/feature.json index db3fc03a..a39d4579 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/043-frictionless-auth" + "feature_directory": "specs/044-session-replay" } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f19066fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +All notable changes to `mixpanel-headless` are recorded here. The format +loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +this project follows semver but is currently pre-1.0, so minor versions +may include API changes. + +## Unreleased — Session Replay (044) + +### Added + +- `Workspace.list_replays(distinct_id|replay_ids, from_date, to_date, limit)` + — discover replays for a user, or hydrate explicit IDs. +- `Workspace.sign_replay(id)` / `Workspace.sign_replays(ids, env)` — + sign replay IDs for CDN access via the + `/app/projects//replays/sign[/bulk]` endpoint. +- `Workspace.fetch_replay(id, env, retention_days, max_files, + include_mixpanel_events, event_properties, cdn_concurrency)` — sign + + parallel CDN walk + return a fully materialized `Replay`. +- `Workspace.stream_replay(id, …)` — sync iterator wrapping the async + CDN walker; re-signs on expiry by default. +- `Workspace.events_for_replay(id, event_properties)` and + `Workspace.events_for_replays(ids, event_properties)` — Mixpanel + events that overlap a replay's time window. +- New result types: `ReplaySummary`, `SignedReplay` (with + `query_string` masked in `__repr__`/`__str__` per FR-008/9), + `ReplayEvent`, `UserAction`, `Replay`. +- New exceptions: `SessionReplayError` (base) plus + `SessionReplayAccessError` (sensitive-data 403), + `SignedURLExpiredError`, `ReplayNotFoundError`, and + `UnsupportedReplayFormatError` (mobile / non-rrweb bytes). CLI + exit-code mapping added: sensitive-data → 2, replay-not-found → 4, + unsupported-format → 1. +- New CLI commands: `mp replays list`, `mp replays events`, + `mp replays sign` (with `--reveal-signed-urls` opt-in that emits a + stderr warning on every invocation), `mp replays fetch [-o FILE]`. +- Depends on the undocumented `/app/projects//replays/sign[/bulk]` + endpoint — the same endpoint Mixpanel's own MCP server uses. +- `Workspace.fetch_replays(ids, …)` — parallel multi-replay fetch + returning a `ReplayBundle`. +- `Workspace.replays_for_user(distinct_id, from_date, to_date, …)` — + composition of `list_replays` + `fetch_replays`; defaults + `include_mixpanel_events=True`. +- `Workspace.analyze_replay(id)` — sugar for + `fetch_replay(id).summary_markdown`. +- `RrwebAnalyzer` (`_internal/replays/rrweb_analyzer.py`) — rrweb + event-stream analyzer producing normalized `UserAction` records + + markdown timeline. Handles click / input / scroll / navigate / + select / console_error event families with per-source debouncing + (scroll / input / selection at 1s each), plus a DOM tracker with + ancestor traversal and descriptive-attrs extraction for + human-readable target descriptions. Pure stdlib. +- `ReplayBundle` (`types.py`): five DataFrame projections + (`sessions_df`, `actions_df`, `events_df`, `mixpanel_df`, + `elements_df`); three aggregations (`top_clicks`, `rage_clicks`, + `long_pauses`); six chainable filters (`filter`, `where`, + `find_pattern`, `error_sessions`, `head`, `sample`); + `join_mixpanel_events`, `summary_markdown`, `compare`. +- Label functions: `default_label_fn`, `selector_label_fn`, + `url_normalizer` (public `replay_labels.py`, re-exported from the + top-level package). The URL normalizer collapses numeric / hex path + segments to `:id` so parameterized URLs aggregate cleanly across users. +- Module-level aggregators (`_internal/replays/aggregators.py`) + re-exposed via `ReplayBundle` methods. +- New CLI commands: `mp replays analyze` (markdown timeline / + `--format json` for action list) and `mp replays for-user + --include analyze --out-dir DIR` (the Mixpanel-events join is on by + default; opt out with `--no-mixpanel-events`). + +### Security + +- `SignedReplay.query_string` is a 5-minute bearer credential and is + masked in `__repr__`/`__str__`. The `--reveal-signed-urls` CLI opt-in + emits a stderr warning on every invocation (FR-008/9). The pre-merge + security audit greps the source tree for `Signature=` / `URLPrefix=` + / `Expires=`; no leaks were found. + +### Notes + +- Mobile session replays are detected by the CDN walker (first event + lacks rrweb's `type`/`data`/`timestamp` keys) and surface as a + typed `UnsupportedReplayFormatError` (a `SessionReplayError`) per + error-messages.md §9 — the CLI maps it to a clean message + exit 1 + instead of leaking a traceback. +- Live integration tests (`tests/integration/test_replays_live.py`) are + marked `@pytest.mark.live` and deselected by default; set + `MP_LIVE_TESTS=1` plus `MP_REPLAY_FIXTURE_DISTINCT_ID` to run them + against a fixture project. diff --git a/CLAUDE.md b/CLAUDE.md index e34050b7..be77d0c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,10 +26,11 @@ Services → DiscoveryService, LiveQueryService Infrastructure → ConfigManager, MixpanelAPIClient ``` -**Three capability areas:** +**Capability areas:** - **Discovery**: Explore schema (events, properties, funnels, cohorts, bookmarks, schema graph) - **Live queries & streaming**: Call Mixpanel API directly (segmentation, funnels, retention, user profiles), stream events and profiles - **Entity CRUD & Data Governance**: Create, read, update, delete dashboards, reports (bookmarks), cohorts, feature flags, experiments, alerts, annotations, webhooks, Lexicon definitions, drop filters, custom properties, custom events, and lookup tables via App API +- **Session replay**: Discover, sign, fetch, and analyze rrweb session recordings (`Workspace.replays_for_user` / `fetch_replay`, `Replay` / `ReplayBundle`, `mp replays`) ## Package Structure @@ -43,6 +44,7 @@ src/mixpanel_headless/ ├── targets.py # `mp.targets` — saved (account, project, workspace?) cursors ├── exceptions.py # Exception hierarchy (incl. AccountInUseError, WorkspaceScopeError) ├── types.py # Result types (SegmentationResult, AccountSummary, …) +├── replay_labels.py # Public replay label helpers (default_label_fn, selector_label_fn, url_normalizer) ├── _internal/ # Private implementation (do not import directly) │ ├── config.py # ConfigManager (TOML-backed) │ ├── api_client.py # MixpanelAPIClient (Session-bound; per-request OAuth bearer) @@ -61,14 +63,15 @@ src/mixpanel_headless/ │ │ ├── callback_server.py # Local HTTP callback server │ │ └── client_registration.py # Dynamic Client Registration (RFC 7591) │ ├── query/ # Query engine builders and validators -│ └── services/ # Discovery, LiveQuery services +│ ├── replays/ # Session-replay analyzer + aggregators (vendored rrweb); public label helpers live in replay_labels.py +│ └── services/ # Discovery, LiveQuery, Replays services └── cli/ ├── main.py # Typer entry point + global flags (-a / -p / -w / -t) ├── commands/ # account / project / workspace / target / session │ # + query, inspect, dashboards, reports, cohorts, flags, │ # experiments, alerts, annotations, webhooks, lexicon, │ # drop-filters, custom-properties, custom-events, - │ # lookup-tables, schemas, business-context + │ # lookup-tables, schemas, business-context, replays ├── formatters.py # JSON, JSONL, Table, CSV, Plain output └── utils.py # Error handling, console helpers ``` @@ -332,8 +335,13 @@ python help.py Filter # type fields + construction patterns + r - N/A — query parameter types only, no persistence (040-query-engine-completeness) - Python 3.10+ (mypy --strict) + httpx, Pydantic v2, Typer, Rich, Hypothesis, mutmut (043-frictionless-auth) - TOML config (`~/.mp/config.toml`) + per-account state at `~/.mp/accounts/{name}/{tokens,client,me}.json` — schema unchanged from 042 (043-frictionless-auth) +- Python 3.10+ (mypy --strict) + httpx, Pydantic v2, pandas, Typer, Rich, Hypothesis, mutmut; vendored rrweb analyzer (pure stdlib) for session replay (044-session-replay) +- N/A — signed URLs are time-bounded bearer credentials handled in-process; no new on-disk persistence (044-session-replay) -**Active plan**: [`specs/043-frictionless-auth/plan.md`](specs/043-frictionless-auth/plan.md) — Frictionless Auth (`mp login` and `/me`-driven discovery). Single PR landing AIE-114/115/116/117 together. +Current plan: [specs/044-session-replay/plan.md](specs/044-session-replay/plan.md) + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan. diff --git a/README.md b/README.md index 148e6ddd..106a5260 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A complete programmable interface to Mixpanel analytics—Python library and CLI Mixpanel's web UI is powerful for interactive exploration, but programmatic access requires navigating multiple REST endpoints with different conventions. **mixpanel_headless** provides a unified interface: discover your schema, run analytics queries, stream data, and manage entities—all through consistent Python methods or CLI commands. -Core analytics—typed Insights engine queries (DAU/WAU/MAU, formulas, filters, breakdowns, cohort-scoped queries, period-over-period comparison, frequency analysis), typed funnel queries (ad-hoc steps, exclusions, conversion windows), typed retention queries (event pairs, custom buckets, alignment modes), typed flow queries (path analysis, direction controls, visualization modes), typed user profile queries (property filtering, sorting, parallel fetching, aggregate statistics), segmentation, saved reports—plus entity management (dashboards, reports, cohorts, feature flags, experiments), and streaming data extraction. +Core analytics—typed Insights engine queries (DAU/WAU/MAU, formulas, filters, breakdowns, cohort-scoped queries, period-over-period comparison, frequency analysis), typed funnel queries (ad-hoc steps, exclusions, conversion windows), typed retention queries (event pairs, custom buckets, alignment modes), typed flow queries (path analysis, direction controls, visualization modes), typed user profile queries (property filtering, sorting, parallel fetching, aggregate statistics), segmentation, saved reports—plus entity management (dashboards, reports, cohorts, feature flags, experiments), streaming data extraction, and session replay (discover, fetch, and analyze rrweb session recordings). ## Installation @@ -264,6 +264,12 @@ schemas = ws.list_schema_registry() enforcement = ws.get_schema_enforcement() audit = ws.run_audit() +# Session replay — discover, fetch, and analyze rrweb recordings +bundle = ws.replays_for_user("user-42", from_date="2025-01-01", to_date="2025-01-31") +print(bundle.sessions_df) # one row per session: duration, clicks, errors +print(bundle.replays[0].summary_markdown) # LLM-friendly action timeline +print(bundle.top_clicks(10)) # most-clicked elements across the bundle + # Stream events for processing for event in ws.stream_events(from_date="2025-01-01", to_date="2025-01-31"): process(event) @@ -323,6 +329,8 @@ for event in ws.stream_events(from_date="2025-01-01", to_date="2025-01-31"): **`mp schemas`** — Schema registry management: `list`, `create`, `create-bulk`, `update`, `update-bulk`, `delete` +**`mp replays`** — Session replay: `list` (discover a user's replays or hydrate explicit IDs), `events` (Mixpanel events during a replay window), `sign` (sign replay IDs for CDN access — redacted by default), `fetch` (pull raw rrweb bytes), `analyze` (render the markdown action timeline), `for-user` (discover + fetch + analyze in one call) + All commands support `--format` (`json`, `jsonl`, `table`, `csv`, `plain`) and `--help`. ### Filtering with --jq @@ -352,6 +360,7 @@ Full documentation: [mixpanel.github.io/mixpanel-headless](https://mixpanel.gith - [Retention Queries](https://mixpanel.github.io/mixpanel-headless/guide/query-retention/) — Typed retention analysis with event pairs, custom buckets, alignment modes - [Flow Queries](https://mixpanel.github.io/mixpanel-headless/guide/query-flows/) — Typed flow path analysis with direction controls, visualization modes - [User Profile Queries](https://mixpanel.github.io/mixpanel-headless/guide/query-users/) — Profile filtering, sorting, parallel fetching, aggregate statistics +- [Session Replay](https://mixpanel.github.io/mixpanel-headless/guide/session-replay/) — Discover, fetch, and analyze rrweb session recordings - [CLI Reference](https://mixpanel.github.io/mixpanel-headless/cli/) - [Python API](https://mixpanel.github.io/mixpanel-headless/api/) - [Streaming Guide](https://mixpanel.github.io/mixpanel-headless/guide/streaming/) @@ -371,6 +380,7 @@ Key design features: - **Consistent interfaces**: Same operations available as Python methods and CLI commands - **Structured output**: All CLI commands support `--format json` for machine-readable responses, plus `--jq` for inline filtering - **Streaming data extraction**: Memory-efficient iterators for events and profiles +- **Session replay**: Discover a user's rrweb recordings, fetch the raw event stream, and project them into session-level DataFrames plus an LLM-friendly action timeline (`replays_for_user`, `Replay`, `ReplayBundle`); signed CDN URLs are masked by default and never logged - **Three first-class account types**: `service_account` (Basic Auth) for unattended automation, `oauth_browser` (PKCE flow with auto-refreshed tokens) for interactive use, `oauth_token` (static bearer) for CI / agents - **Typed exceptions**: Error codes and context for programmatic handling diff --git a/context/session-replay-plan.md b/context/session-replay-plan.md new file mode 100644 index 00000000..6d2a24ac --- /dev/null +++ b/context/session-replay-plan.md @@ -0,0 +1,1429 @@ +# Implementation Plan: Session Replay for `mixpanel-headless` + +**Branch**: `044-session-replay` (proposed) | **Date**: 2026-05-27 | **Author**: Jared McFarland +**Status**: Design (no code written) +**Source research**: [`jared-shares/2026-05/mixpanel-headless-session-replay-design.html`](https://storage.googleapis.com/jared-shares/2026-05/mixpanel-headless-session-replay-design.html) +**PR strategy**: Phased. Phase 1 (discovery + signed CDN access + `Replay`) ships as one PR. Phase 2 (vendored rrweb analyzer + `ReplayBundle` data model) ships as a second PR. Phase 3 (pm4py + tslearn optional extras) ships as a third PR, gated on user demand. Each phase is independently shippable and adds value. + +--- + +## Summary + +Add a first-class session replay surface to `mixpanel-headless` covering: + +1. **Discovery** of replays for a user via the existing Insights Query API (`Workspace.query()`), grouped on `$mp_replay_id` and `$mp_replay_retention_period`. +2. **Signed CDN access** to raw rrweb recording files via Mixpanel's `/app/projects//replays/sign[/bulk]` endpoints, with both streaming and buffered fetch. +3. **Vendored rrweb analyzer** that converts raw rrweb event streams into normalized user-action timelines (DOM tracker + event interpreter + markdown reporter, ported from `analytics/backend/replays/rrweb_analyzer.py`). +4. **Two typed result classes** — `Replay` (single) and `ReplayBundle` (collection) — exposing long-format pandas DataFrames keyed by `replay_id`, lazy `networkx` page and element graphs, lazy `anytree` path trees, and a lazy `pm4py` event log for process mining. +5. **Convenience aggregations** matching the `FlowQueryResult` idiom: `top_paths()`, `top_clicks()`, `top_pages()`, `dead_clicks()`, `rage_clicks()`, `long_pauses()`, `error_sessions()`, plus bundle-returning filters (`filter()`, `where()`, `find_pattern()`) that chain cleanly. +6. **CLI group** `mp replays` with `list`, `events`, `sign`, `fetch`, `analyze`, and `for-user` commands. + +The design treats a replay as an event log (timestamped activities keyed by a case ID) so the data shape lines up with every PyData library that touches sequential data — `pandas`, `pm4py`, `prefixspan`, `tslearn`, `duckdb`. The `ReplayBundle` is the high-leverage type; a `Replay` is conceptually a bundle of size 1, and the API treats them that way. + +The work depends on one undocumented App API endpoint (`/replays/sign[/bulk]`), which is the same endpoint Mixpanel's own MCP server uses. `mixpanel-headless` is now an official second client to mixpanel.com and is already at near-parity on undocumented-API usage, so no special gating is needed beyond the existing pre-release version warning. + +Estimated scope: ~3,000 LoC across ~25 new/modified files, three phases, total ~3-4 weeks of focused work. + +--- + +## Technical Context + +**Language/Version**: Python 3.10+ (mypy --strict compliant) + +**Primary Dependencies**: +- Existing: `httpx` (HTTP client and CDN fetcher), `pydantic` v2 (validation), `pandas` (DataFrames), Typer (CLI), Rich (output), Hypothesis (PBT), mutmut (mutation testing) +- New (vendored, no third-party install): rrweb analyzer ported from analytics monorepo (~600 LoC, pure stdlib) +- New optional: `pm4py` (process mining; behind `replay-mining` extra), `tslearn` (DTW clustering; behind `replay-ml` extra), `networkx` and `anytree` (already optional via existing extras, reused) + +**Storage**: None. Signed URLs are time-bounded bearer credentials; the library does not persist them. No new disk artifacts beyond what `httpx` already handles. + +**Testing**: pytest (unit + integration); Hypothesis PBT for label-fn stability, file-numbering walker, and DataFrame projection invariants; mutmut on the vendored analyzer + new query builders. Integration tests gated on a known replay-bearing project (Mixpanel Labs internal project ID 3713224 or equivalent fixture). + +**Target Platform**: Cross-platform (macOS, Linux, Windows). + +**Performance Goals**: +- `list_replays(distinct_id, from_date, to_date)` ≤ 1 round trip to `/api/query/insights` for any date range up to 90 days. +- `sign_replays(ids)` ≤ 1 round trip for up to 1000 replay IDs. +- `fetch_replay(replay_id)` parallel CDN fetch with concurrency 50, terminates on first 404; for a typical 30 MB replay, expect under 5 s on a typical broadband connection. +- `stream_replay(replay_id)` first event yielded within 1 s of call (signed URL + first file fetch). +- `Replay.actions_df` materialization ≤ 200 ms for a 30 MB replay. +- `ReplayBundle.actions_df` materialization ≤ 100 ms per replay in the bundle (linear scaling). + +**Constraints**: +- mypy --strict, zero unjustified `Any`. +- ruff format/check passes with zero violations. +- 90% test coverage minimum (CI fails below). +- 80% mutation score on new pure modules (`_internal/services/replays.py`, `_internal/replays/rrweb_analyzer.py`, `_internal/replays/labels.py`). +- Signed-URL `query_string` MUST NOT appear in any log line at any level (INFO/DEBUG/WARNING). `__repr__` of `SignedReplay` MUST mask the `query_string` field. +- Vendored analyzer MUST remain pure-Python (no native deps) so it works in every environment `mixpanel-headless` already supports. +- Optional extras (`replay-mining`, `replay-ml`) MUST NOT be required for any core surface to import; lazy imports inside property bodies and method bodies. + +**Scale/Scope**: +- Phase 1: 5 new files, ~1,200 LoC including tests. +- Phase 2: 4 new files (vendored analyzer, labels, ReplayBundle expansion, tests), ~1,500 LoC. +- Phase 3: 2 new files (pm4py adapter, tslearn adapter), ~500 LoC. + +--- + +## Out of Scope (explicit) + +To keep the first cuts focused: + +- **Mobile session replays.** Different recording format. The MCP server has an open TODO (SR-230). Discovery still works since `$mp_session_record` / `$mp_replay_id` are platform-agnostic, but the bytes layer and analyzer are web-only in this design. +- **Direct GCS access via internal service account.** The `gcs_fetcher.py` path in `analytics/backend/replays/` is reserved for Mixpanel's own P3 project. Not relevant or accessible to external callers. +- **Replay bookmarking / saved-report integration.** Replays aren't bookmarkable through the App API and there's no clear product fit for that yet. +- **LLM-based replay summarization.** The vendored analyzer produces a deterministic markdown timeline. LLM enrichment is a downstream concern — `mixpanelyst` skill or user code can layer it on. +- **Real-time replay streaming.** Replays are batched and uploaded every 10 s by the SDK and become available shortly after; this design is for retrospective analysis, not live tailing. +- **Replay deletion / retention management.** Out of scope for the read-side library. +- **Cohort-driven replay enumeration.** Listing replays for all members of a cohort is a natural extension but adds a join layer; can ship in a follow-up if there's demand. + +--- + +## Functional Requirements + +### Discovery + +- **FR-001**: `Workspace.list_replays()` MUST accept either a `distinct_id` (with `from_date` / `to_date`) or an explicit list of `replay_ids`. Both paths return `list[ReplaySummary]`. +- **FR-002**: Discovery MUST use the Insights Query API (`Workspace.query()`), grouping on `$mp_replay_id` AND `$mp_replay_retention_period` AND `$time`, never the legacy Segmentation API. +- **FR-003**: When no `$mp_session_record` events are found in range, `list_replays(distinct_id=...)` MUST return an empty list, never raise. Empty-result handling is the caller's responsibility. +- **FR-004**: The retention period for each replay MUST be read from the discovered `$mp_replay_retention_period` property, not hardcoded. When the property is missing (older replays, edge cases), default to 30 days with a structured warning. +- **FR-005**: `Workspace.events_for_replay(replay_id)` and `Workspace.events_for_replays(replay_ids)` MUST query the Insights API for events filtered on `$mp_replay_id`, excluding `$mp_session_record` itself, optionally including up to 5 additional event properties as group keys. + +### Signed CDN Access + +- **FR-006**: `Workspace.sign_replays(replay_ids, env="prod")` MUST POST to `/app/projects//replays/sign/bulk` with the bulk shape, returning `list[SignedReplay]`. Single-replay sugar `sign_replay(replay_id)` is a thin wrapper around the bulk call. +- **FR-007**: A 403 response indicating the `SESSION_RECORDING_SENSITIVE_DATA` project flag is set MUST raise `SessionReplayAccessError` with structured details (project_id, hint to contact project owner). Other 4xx/5xx errors flow through the existing `QueryError` / `ServerError` mappings. +- **FR-008**: `SignedReplay` instances MUST mask the `query_string` field in `__repr__` and `__str__`. Default Python logging of a `SignedReplay` MUST NOT leak the signed credential. +- **FR-009**: The library MUST NOT log `query_string` at any log level. Logging the URL prefix (without query string) is acceptable at DEBUG. +- **FR-010**: Signed URLs have a 5-minute server-side expiration. `Workspace.stream_replay()` MUST re-sign on demand if a fetch fails with a 403 indicating signature expiration; `Workspace.fetch_replay()` does not need this because it signs and fetches in immediate succession. + +### CDN Fetching + +- **FR-011**: `Workspace.fetch_replay(replay_id)` MUST sign, fetch all CDN files in parallel (concurrency 50, batches), concatenate, sort by timestamp, and return a `Replay`. +- **FR-012**: `Workspace.stream_replay(replay_id)` MUST yield rrweb events one at a time. Implementation: fetch files in batches of 50 in parallel; within a batch, yield events in timestamp order; do not buffer across batches. +- **FR-013**: CDN file naming MUST use the per-replay retention period from FR-004: `{prefix}{N:04d}-{retention_days}.json`. +- **FR-014**: Fetching MUST terminate cleanly on the first 404 (signal of end-of-replay), not retry it. Other HTTP errors are retried per the existing `MixpanelAPIClient` policy. +- **FR-015**: A configurable `max_files` parameter (default 500; MCP server uses 200) bounds runaway fetches if the 404 sentinel is missed. + +### Single-Replay Result Type + +- **FR-016**: `Replay` MUST be a frozen dataclass inheriting from the existing `ResultWithDataFrame` mixin. +- **FR-017**: `Replay` MUST expose `rrweb_events`, `actions`, `mixpanel_events` as raw lists, and `events_df`, `actions_df`, `mixpanel_df`, `pages_df` as cached lazy DataFrame properties. +- **FR-018**: `Replay.df` MUST return `actions_df` by default (the most useful projection for typical analysis). +- **FR-019**: `Replay` MUST expose convenience methods `duration_seconds`, `page_path()`, `errors`, `clicks_on(selector)`, `summary_markdown`, `to_rrweb_player_json()`. + +### Bundle Result Type + +- **FR-020**: `ReplayBundle` MUST be a frozen dataclass inheriting from `ResultWithDataFrame`. +- **FR-021**: `ReplayBundle` MUST expose `replays: list[Replay]` and the seven cached lazy DataFrame projections: `sessions_df`, `actions_df`, `events_df`, `mixpanel_df`, `pages_df`, `elements_df`, `transitions_df`. +- **FR-022**: `ReplayBundle.df` MUST return `sessions_df` by default (one row per replay, most useful default). +- **FR-023**: `ReplayBundle` MUST expose graph projections `page_graph`, `element_graph`, `path_tree` as cached lazy properties using `networkx` and `anytree` respectively. These properties MUST lazy-import their dependencies inside the property body. +- **FR-024**: `ReplayBundle.event_log` MUST return a pm4py-compatible `pandas.DataFrame` (with renamed columns `case:concept:name`, `concept:name`, `time:timestamp`) when `pm4py` is not installed, or a `pm4py.objects.log.obj.EventLog` when it is. The lazy-import pattern from FR-023 applies. +- **FR-025**: `ReplayBundle` MUST expose convenience aggregations matching the `FlowQueryResult` idiom: `top_paths(n=10)`, `top_clicks(n=10)`, `top_pages(n=10)`, `dead_clicks(window_ms=200)`, `rage_clicks(threshold=3, window_ms=1000)`, `long_pauses(threshold_s=10)`, `error_sessions()`. +- **FR-026**: `ReplayBundle` MUST expose chainable filter methods that return new `ReplayBundle` instances: `filter(predicate)`, `where(**kwargs)`, `find_pattern(action_sequence)`, `head(n)`, `sample(n, seed=None)`. +- **FR-027**: `ReplayBundle.join_mixpanel_events(properties=None)` MUST enrich the bundle with `mixpanel_df` data fetched lazily on first access. +- **FR-028**: `ReplayBundle.summary_markdown` MUST produce a concise multi-session overview suitable for LLM consumption. + +### Action Labeling + +- **FR-029**: `ReplayBundle.event_log`, `top_paths()`, `find_pattern()`, and any process-mining-bound method MUST accept an optional `label_fn: Callable[[UserAction], str]` parameter for user-controlled activity labeling. +- **FR-030**: The default label function MUST produce stable labels of the shape `f"{action}:{tag_name}@{normalized_url}"` — coarse enough to align across sessions, specific enough to be meaningful. +- **FR-031**: A built-in `labels.selector_label_fn(attr="data-testid")` MUST be provided for projects that tag interactive elements with stable identifiers. + +### CLI + +- **FR-032**: A new `mp replays` Typer group MUST be registered in `cli/main.py::_register_commands()`. +- **FR-033**: Commands MUST follow the existing pattern: `@handle_errors`, `get_workspace(ctx)`, `output_result(ctx, ..., format=format)`. +- **FR-034**: The CLI surface MUST be: `list`, `events`, `sign`, `fetch`, `analyze`, `for-user` (sugar that composes `list` + `analyze`). +- **FR-035**: `mp replays sign` MUST default to redacted output (URL prefix only). A `--reveal-signed-urls` flag opts into the full output (required for actual CDN use); the flag MUST emit a stderr warning that signed URLs are bearer credentials. +- **FR-036**: `mp replays fetch -o file.json` MUST write a JSON array of rrweb events directly compatible with the rrweb JS player. +- **FR-037**: `mp replays analyze ` MUST print the markdown timeline to stdout. + +### Optional Extras + +- **FR-038**: `pyproject.toml` MUST define three new install extras: + - `replay-analyze`: vendored analyzer is in core (always available); this extra is reserved as a stable name for future analyzer-only deps. + - `replay-mining`: `pm4py>=2.7` + - `replay-ml`: `tslearn>=0.6` + - `replay-all`: union of the above plus `networkx` and `anytree` (already extras) +- **FR-039**: Importing `mixpanel_headless` and instantiating `Workspace` MUST succeed with none of the optional extras installed. +- **FR-040**: When an optional dep is missing and the corresponding property is accessed, the error MUST be a clear `ImportError` with the install command in the message: `f"To use ReplayBundle.event_log, install: pip install 'mixpanel-headless[replay-mining]'"`. + +### Security + +- **FR-041**: Signed URLs MUST be treated as bearer credentials in all library logging, repr, and error contexts. `SignedReplay.__repr__` masks `query_string` as `""`. +- **FR-042**: The library MUST translate the `SESSION_RECORDING_SENSITIVE_DATA` 403 into `SessionReplayAccessError` with structured `details = {"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA"}` and an actionable message. +- **FR-043**: `mp replays sign` CLI output MUST default to redacted; the `--reveal-signed-urls` flag is required to opt into the bearer-credential output and MUST emit a stderr warning. + +--- + +## Architecture + +``` + ┌─────────────────────────────────────┐ + │ Workspace (public facade) │ + │ list_replays, sign_replays, │ + │ fetch_replay, stream_replay, │ + │ analyze_replay, events_for_replay │ + └──────────────────┬──────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ + │ ReplaysService │ │ MixpanelAPIClient │ │ Insights query path │ + │ _internal/services/ │ │ (existing) │ │ (existing) │ + │ replays.py │ │ │ │ │ + │ │ │ • sign_replays │ │ • $mp_session_record│ + │ • orchestrates │ │ • CDN fetch (httpx) │ │ discovery │ + │ sign + fetch + │ │ • error mapping │ │ • $mp_replay_id │ + │ analyze pipeline │ │ │ │ event window join │ + └────────┬────────────┘ └─────────────────────┘ └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ rrweb_analyzer │ + │ _internal/replays/ │ + │ rrweb_analyzer.py │ + │ (vendored, pure-py) │ + │ │ + │ • DOMTracker │ + │ • EventAnalyzer │ + │ • MarkdownReporter │ + └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ labels.py │ + │ _internal/replays/ │ + │ │ + │ • default_label_fn │ + │ • selector_label_fn │ + │ • url_normalizer │ + └─────────────────────┘ + + Result types (mixpanel_headless.types): + SignedReplay, ReplayEvent, UserAction, Replay, ReplayBundle, ReplaySummary +``` + +### Layered pipeline + +1. **Discovery** — `Insights Query API` via existing `Workspace.query()`. Returns `(replay_id, retention_days, start_time)` triples per `$mp_session_record` event. +2. **Sign** — `POST /app/projects//replays/sign/bulk`. Returns prefix URL + signed query string (5-min TTL) per replay. +3. **Fetch** — Parallel HTTPS GETs to `cdn.mxpnl.com/srr-{region}//-.json?`. Terminate on first 404. +4. **Parse** — Vendored rrweb analyzer walks the concatenated event stream, maintains DOM state, emits `UserAction` records. +5. **Aggregate** — `ReplayBundle` materializes long-format DataFrames and lazy graph/tree/event-log projections from action records. +6. **Mine (optional, behind extras)** — `pm4py` discovers process structure from the event log; `tslearn` clusters sessions by action sequence. +7. **Render** — CLI formatters / DataFrame heads / markdown timelines / Graphviz exports. + +--- + +## API Surface — Python + +### Workspace methods + +All methods are added to `mixpanel_headless.workspace.Workspace`. They follow the existing pattern of calling either `self._api` for direct HTTP or a service from `self._services` for orchestrated work. + +```python +class Workspace: + + # ─── Discovery ────────────────────────────────────────────────────── + + def list_replays( + self, + *, + distinct_id: str | None = None, + replay_ids: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + limit: int = 100, + ) -> list[ReplaySummary]: + """List replays for a user or hydrate summaries for explicit IDs. + + Exactly one of (distinct_id, replay_ids) must be provided. When + distinct_id is given, from_date and to_date are required. + + Implementation: queries $mp_session_record via the Insights API, + grouped on $mp_replay_id and $mp_replay_retention_period. + """ + + def events_for_replay( + self, + replay_id: str, + *, + event_properties: list[str] | None = None, + ) -> list[ReplayEvent]: + """Mixpanel events that occurred during a replay's window. + + Queries the Insights API filtered on $mp_replay_id, excluding + $mp_session_record. At most 5 event_properties can be requested + (Insights group-clause limit). + """ + + def events_for_replays( + self, + replay_ids: list[str], + *, + event_properties: list[str] | None = None, + ) -> dict[str, list[ReplayEvent]]: + """Batch version of events_for_replay, single Insights round-trip.""" + + # ─── Signed access ────────────────────────────────────────────────── + + def sign_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + ) -> SignedReplay: + """Single-replay signing sugar over sign_replays.""" + + def sign_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", + ) -> list[SignedReplay]: + """POST to /app/projects//replays/sign/bulk.""" + + # ─── Fetch ────────────────────────────────────────────────────────── + + def fetch_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + ) -> Replay: + """Sign + fetch + parse + return a populated Replay. + + When retention_days is None, list_replays is consulted first to + discover the actual retention period for this replay. Pass + retention_days explicitly to skip the discovery round trip. + + include_mixpanel_events=True triggers a follow-up Insights query + to populate Replay.mixpanel_events. + """ + + def stream_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + re_sign_on_expiry: bool = True, + ) -> Iterator[dict[str, Any]]: + """Yield rrweb events one at a time, batched-parallel under the hood. + + re_sign_on_expiry=True re-signs the URL on a 403 indicating + signature expiration. False propagates the 403 as a SignedURLExpiredError. + """ + + # ─── Bundle (collection) ──────────────────────────────────────────── + + def fetch_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + concurrency: int = 4, + ) -> ReplayBundle: + """Sign + fetch + parse N replays in parallel, return a ReplayBundle. + + concurrency controls how many replays are fetched in parallel. + Within each replay, CDN files are fetched at concurrency 50. + """ + + def replays_for_user( + self, + distinct_id: str, + *, + from_date: str, + to_date: str, + limit: int = 100, + include_mixpanel_events: bool = True, + event_properties: list[str] | None = None, + ) -> ReplayBundle: + """Discovery + fetch in one call. The 'I want this user's recent + activity' convenience method. + """ + + # ─── Analysis ─────────────────────────────────────────────────────── + + def analyze_replay(self, replay_id: str) -> str: + """Sign + fetch + analyzer.analyze_events + return the markdown timeline. + + Sugar for: ws.fetch_replay(id).summary_markdown. + """ +``` + +### Result types + +```python +# mixpanel_headless.types (new additions) + +@dataclass(frozen=True) +class ReplaySummary(ResultWithDataFrame): + """Lightweight handle to a replay, returned by list_replays. + + Does NOT include recording bytes or normalized actions. Use + ws.fetch_replay(summary.replay_id) to materialize the full Replay. + """ + replay_id: str + distinct_id: str | None + project_id: int + start_time: int # unix ms (from $mp_session_record event timestamp) + retention_days: int + +@dataclass(frozen=True) +class SignedReplay: + """Time-bounded signed CDN access for one replay. + + SECURITY: query_string is a bearer credential valid for ~5 minutes. + Treat it like a session token. __repr__ masks it; do not log it. + """ + replay_id: str + url: str # prefix, includes trailing slash + query_string: str # MASKED IN __repr__ + env: Literal["prod", "dev"] + signed_at: float # unix seconds (for expiration arithmetic) + + def __repr__(self) -> str: + masked = f"" + return ( + f"SignedReplay(replay_id={self.replay_id!r}, url={self.url!r}, " + f"query_string={masked!r}, env={self.env!r}, signed_at={self.signed_at!r})" + ) + + @property + def expires_at(self) -> float: + """Approximate expiration timestamp (signed_at + 5 minutes).""" + return self.signed_at + 300 + + @property + def is_expired(self) -> bool: + return time.time() >= self.expires_at + +@dataclass(frozen=True) +class UserAction: + """Normalized user action extracted from rrweb events by the analyzer.""" + timestamp: int # unix ms + action: str # 'click' | 'input' | 'scroll' | 'navigate' | 'select' | 'console_error' | ... + target_node_id: int | None + target_desc: str # e.g. 'button "Sign in"' + url: str | None + metadata: dict[str, Any] + +@dataclass(frozen=True) +class ReplayEvent(ResultWithDataFrame): + """Mixpanel event that occurred during a replay's time window.""" + replay_id: str + event_name: str + event_time: int # unix seconds + properties: dict[str, Any] | None + +@dataclass(frozen=True) +class Replay(ResultWithDataFrame): + """Single fully-materialized replay. + + A Replay is conceptually a ReplayBundle of size 1; the same + DataFrame projections are available on both. + """ + replay_id: str + distinct_id: str | None + project_id: int + start_time: int # unix ms + end_time: int + retention_days: int + + rrweb_events: list[dict[str, Any]] + actions: list[UserAction] + mixpanel_events: list[ReplayEvent] # empty unless include_mixpanel_events was True + + # cached projections + _events_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _actions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _mixpanel_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _pages_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + + @property + def events_df(self) -> pd.DataFrame: ... + @property + def actions_df(self) -> pd.DataFrame: ... + @property + def mixpanel_df(self) -> pd.DataFrame: ... + @property + def pages_df(self) -> pd.DataFrame: ... + @property + def df(self) -> pd.DataFrame: + """Default projection: actions_df.""" + return self.actions_df + + @property + def duration_seconds(self) -> float: ... + @property + def errors(self) -> pd.DataFrame: ... + @property + def summary_markdown(self) -> str: ... + def page_path(self) -> list[str]: ... + def clicks_on(self, predicate: Callable[[UserAction], bool]) -> pd.DataFrame: ... + def to_rrweb_player_json(self) -> list[dict[str, Any]]: + """Return rrweb_events sorted by timestamp, ready for the rrweb JS player.""" + +@dataclass(frozen=True) +class ReplayBundle(ResultWithDataFrame): + """Collection of replays with cross-session DataFrame and graph projections.""" + replays: list[Replay] + computed_at: str + project_id: int + + # cached projections (DataFrames) + _sessions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _actions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _events_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _mixpanel_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _pages_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _elements_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _transitions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + + # cached projections (graphs) + _page_graph_cache: object | None = field(default=None, repr=False, kw_only=True) + _element_graph_cache: object | None = field(default=None, repr=False, kw_only=True) + _path_tree_cache: object | None = field(default=None, repr=False, kw_only=True) + + # DataFrame projections (see Data Model section for full columns) + @property + def sessions_df(self) -> pd.DataFrame: ... + @property + def actions_df(self) -> pd.DataFrame: ... + @property + def events_df(self) -> pd.DataFrame: ... + @property + def mixpanel_df(self) -> pd.DataFrame: ... + @property + def pages_df(self) -> pd.DataFrame: ... + @property + def elements_df(self) -> pd.DataFrame: ... + @property + def transitions_df(self) -> pd.DataFrame: ... + @property + def df(self) -> pd.DataFrame: + """Default projection: sessions_df.""" + return self.sessions_df + + # Graph and tree projections (lazy, optional deps) + @property + def page_graph(self) -> "networkx.DiGraph": ... + @property + def element_graph(self) -> "networkx.DiGraph": ... + @property + def path_tree(self) -> "anytree.AnyNode": ... + + # Event log for process mining (DataFrame fallback when pm4py not installed) + def event_log( + self, + *, + label_fn: Callable[[UserAction], str] | None = None, + ) -> "pd.DataFrame | pm4py.objects.log.obj.EventLog": ... + + # Aggregations (return DataFrames) + def top_paths(self, n: int = 10, *, label_fn: Callable[[UserAction], str] | None = None) -> pd.DataFrame: ... + def top_pages(self, n: int = 10) -> pd.DataFrame: ... + def top_clicks(self, n: int = 10) -> pd.DataFrame: ... + def dead_clicks(self, window_ms: int = 200) -> pd.DataFrame: ... + def rage_clicks(self, threshold: int = 3, window_ms: int = 1000) -> pd.DataFrame: ... + def long_pauses(self, threshold_s: float = 10) -> pd.DataFrame: ... + + # Filters (return new ReplayBundle) + def filter(self, predicate: Callable[[Replay], bool]) -> "ReplayBundle": ... + def where( + self, + *, + distinct_id: str | None = None, + contains_url: str | None = None, + has_event: str | None = None, + min_duration_s: float | None = None, + max_duration_s: float | None = None, + ) -> "ReplayBundle": ... + def find_pattern( + self, + action_sequence: list[str], + *, + label_fn: Callable[[UserAction], str] | None = None, + ) -> "ReplayBundle": ... + def error_sessions(self) -> "ReplayBundle": ... + def head(self, n: int = 5) -> "ReplayBundle": ... + def sample(self, n: int = 5, seed: int | None = None) -> "ReplayBundle": ... + + # Enrichment + def join_mixpanel_events( + self, + properties: list[str] | None = None, + ) -> "ReplayBundle": + """Return a new bundle with mixpanel_events populated on every Replay.""" + + # Summary / comparison + @property + def summary_markdown(self) -> str: ... + def compare(self, other: "ReplayBundle") -> pd.DataFrame: + """Action-frequency diff vs another bundle (e.g., converters vs non-converters).""" + + # ML (lives in Phase 3, optional) + def cluster(self, n: int = 5, *, features: Literal["actions", "pages"] = "actions") -> "ReplayBundle": + """Add a cluster_label property to each Replay (uses tslearn DTW). Requires [replay-ml] extra.""" +``` + +### Exception hierarchy additions + +```python +# mixpanel_headless.exceptions (new additions) + +class SessionReplayError(APIError): + """Base for session-replay-specific errors.""" + +class SessionReplayAccessError(SessionReplayError): + """The project has SESSION_RECORDING_SENSITIVE_DATA enabled and the + caller lacks sensitive-data access. Contact the project owner. + """ + # details = {"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA"} + +class SignedURLExpiredError(SessionReplayError): + """The signed URL passed to a CDN fetch has expired (5-minute TTL). + Re-sign and retry. + """ + +class ReplayNotFoundError(SessionReplayError): + """A specific replay_id was requested but no CDN files were found. + The replay may have aged out of retention, never been recorded, or + been deleted. + """ +``` + +### Public exports + +`mixpanel_headless/__init__.py` adds to `__all__`: + +```python +# Session Replay (Phase 044) +"Replay", +"ReplayBundle", +"ReplaySummary", +"SignedReplay", +"ReplayEvent", +"UserAction", +"SessionReplayError", +"SessionReplayAccessError", +"SignedURLExpiredError", +"ReplayNotFoundError", +``` + +--- + +## API Surface — CLI + +A new Typer group at `cli/commands/replays.py`, registered in `cli/main.py::_register_commands()`. + +### Commands + +```bash +# Discovery +mp replays list --user abc-123 --from 2026-05-01 --to 2026-05-27 +mp replays list --user abc-123 --from 2026-05-01 --to 2026-05-27 --format table +mp replays events +mp replays events --properties '$browser,$current_url' + +# Signed access (defaults to redacted output; --reveal-signed-urls opts in) +mp replays sign [...] --env prod +mp replays sign --reveal-signed-urls --format jsonl + +# Fetch raw recording bytes +mp replays fetch -o recording.json +mp replays fetch --include-events -o recording_with_events.json + +# Analysis (vendored rrweb analyzer) +mp replays analyze +mp replays analyze --format json # structured action list + +# Sugar — combines list + fetch + analyze +mp replays for-user abc-123 --from 2026-05-01 --to 2026-05-27 \ + --include analyze --include events \ + --out-dir ./replays/ +``` + +### Output formats + +Follows existing convention via `FormatOption`: + +- `list`: defaults to `table` (columns: replay_id, distinct_id, started, retention) +- `events`: defaults to `json` +- `sign`: defaults to `json` (redacted unless `--reveal-signed-urls`) +- `fetch`: writes raw JSON to the file given via `-o`; without `-o`, writes a one-line summary to stdout +- `analyze`: defaults to `plain` (the markdown timeline); `--format json` returns the structured action list + +### Examples + +```bash +# Quickly inspect what a user did last week +mp replays for-user user-42 --from 2026-05-20 --to 2026-05-27 --include analyze + +# Pull a single replay for offline analysis with the rrweb JS player +mp replays fetch r-19221397401184 -o replay.json +# Then in a browser: +# import rrwebPlayer from 'rrweb-player'; +# const events = await (await fetch('replay.json')).json(); +# new rrwebPlayer({ target: document.body, props: { events } }); + +# Get raw signed URLs for a custom CDN-fetch pipeline +mp replays sign r-a r-b r-c --reveal-signed-urls --format jsonl > urls.jsonl +``` + +--- + +## Data Model + +### Discovery query shape + +For `list_replays(distinct_id=..., from_date=..., to_date=...)`: + +```python +result = ws.query( + "$mp_session_record", + from_date=from_date, + to_date=to_date, + where=Filter( + property="$distinct_id", + operator="equals", + values=[distinct_id], + ), + group_by=[ + "$mp_replay_id", + "$mp_replay_retention_period", + ], + mode="table", +) +``` + +The resulting `QueryResult.df` is shaped roughly: + +| date | $mp_replay_id | $mp_replay_retention_period | count | +|---|---|---|---| +| 2026-05-21 | r-19221... | 30 | 1 | +| 2026-05-21 | r-19222... | 30 | 1 | +| 2026-05-22 | r-19223... | 30 | 1 | + +`list_replays` reshapes this into `list[ReplaySummary]`, taking the earliest `date` per `replay_id` as the start time. + +### `events_for_replay` query shape + +```python +group_keys = ["$time", "$event_name", "$mp_replay_id"] +if event_properties: + group_keys += event_properties + +result = ws.query( + "$all_events", # behavior-set query, not a literal event + from_date=replay_start_date, + to_date=replay_end_date, + where=[ + Filter(property="$mp_replay_id", operator="equals", values=[replay_id]), + Filter(property="$event_name", operator="does not equal", values=["$mp_session_record"]), + ], + group_by=group_keys, + mode="table", +) +``` + +The exact bookmark shape mirrors the MCP server's `build_replay_events_request` in `analytics/backend/replays/query_utils.py` but routed through `Workspace.query()` instead of constructing an `InsightsBookmarkParams` directly. The Phase 029 typed Insights surface in `mixpanel-headless` supports this group-by shape. + +### CDN fetch pattern + +After signing, the CDN access pattern is: + +``` +https://cdn.mxpnl.com/srr-{us|eu|in}/{sha256(replay_id)}-{project_id}/{NNNN}-{retention_days}.json?{query_string} +``` + +The library walks `NNNN` from `0000` upward in parallel batches of 50, stops on first `404`. The retention period comes from `ReplaySummary.retention_days` (discovered via FR-004), not hardcoded. + +Each `NNNN-N.json` is a JSON array of rrweb event objects. The library concatenates them and sorts by `timestamp` (rrweb timestamps are unix ms). + +### `Replay` DataFrame columns + +**`events_df`** — flat rrweb events: + +| Column | Type | Notes | +|---|---|---| +| `t` | `int64` | rrweb timestamp (unix ms) | +| `type` | `category` | rrweb EventType: `DomContentLoaded` (0), `Load` (1), `FullSnapshot` (2), `IncrementalSnapshot` (3), `Meta` (4), `Custom` (5), `Plugin` (6) | +| `source` | `category` | `IncrementalSource` for type=3 events; null otherwise. Values: `Mutation`, `MouseMove`, `MouseInteraction`, `Scroll`, `ViewportResize`, `Input`, `TouchMove`, `MediaInteraction`, `StyleSheetRule`, `CanvasMutation`, `Font`, `Log`, `Drag`, `StyleDeclaration`, `Selection` | +| `mouse_type` | `category` | For MouseInteraction events: `click`, `dbl_click`, `context_menu`, `focus`, `touch_start`, ... | +| `target_node_id` | `Int64` | nullable; the rrweb node ID the event targets | +| `url` | `string` | extracted from Meta events; null otherwise | +| `raw` | `object` | full original rrweb dict for callers that need everything | + +**`actions_df`** — normalized actions from the analyzer: + +| Column | Type | Notes | +|---|---|---| +| `t` | `int64` | unix ms | +| `action` | `category` | `click`, `input`, `scroll`, `navigate`, `select`, `console_error`, ... | +| `target_node_id` | `Int64` | nullable | +| `target_desc` | `string` | `'button "Sign in"'`, `'input[type=email]'`, ... | +| `url` | `string` | active page URL at the time of the action | +| `metadata` | `object` | dict (text_length, is_checked, range_count, etc.) | + +**`pages_df`** — one row per Meta navigation event: + +| Column | Type | Notes | +|---|---|---| +| `t` | `int64` | unix ms when navigation happened | +| `url` | `string` | destination URL | +| `dwell_ms` | `int64` | time until next navigation (or end of replay) | + +**`mixpanel_df`** — empty unless populated; same columns as the bundle's `mixpanel_df`. + +### `ReplayBundle` DataFrame columns + +**`sessions_df`** — one row per replay: + +| Column | Type | Notes | +|---|---|---| +| `replay_id` | `string` | primary key | +| `distinct_id` | `string` | nullable | +| `start_time` | `datetime64[ns, UTC]` | from rrweb | +| `end_time` | `datetime64[ns, UTC]` | from rrweb | +| `duration_s` | `float64` | end - start | +| `retention_days` | `Int16` | from `$mp_replay_retention_period` | +| `n_events` | `Int32` | rrweb event count | +| `n_actions` | `Int32` | normalized action count | +| `n_clicks` | `Int32` | | +| `n_inputs` | `Int32` | | +| `n_pages` | `Int32` | distinct URLs visited | +| `n_errors` | `Int32` | console errors | +| `n_mp_events` | `Int32` | Mixpanel events joined in (0 unless joined) | +| `entry_url` | `string` | first URL visited | +| `exit_url` | `string` | last URL visited | +| `dead_click_count` | `Int32` | clicks with no DOM mutation within 200ms | +| `rage_click_count` | `Int32` | ≥3 clicks on same target within 1s | +| `longest_pause_s` | `float64` | longest gap between consecutive actions | + +**`actions_df`** — long format, all bundle replays: + +| Column | Type | Notes | +|---|---|---| +| `replay_id` | `string` | foreign key to sessions_df | +| `t` | `datetime64[ns, UTC]` | normalized to UTC | +| `action` | `category` | | +| `target_node_id` | `Int64` | nullable | +| `target_desc` | `string` | | +| `url` | `string` | | + +**`events_df`** — long format, raw rrweb events across the bundle; same columns as `Replay.events_df` plus `replay_id`. + +**`mixpanel_df`** — long format, Mixpanel events across the bundle: + +| Column | Type | Notes | +|---|---|---| +| `replay_id` | `string` | foreign key | +| `t` | `datetime64[ns, UTC]` | | +| `event_name` | `string` | | +| `properties` | `object` | dict | + +**`pages_df`** — long format page visits: + +| Column | Type | Notes | +|---|---|---| +| `replay_id` | `string` | | +| `t` | `datetime64[ns, UTC]` | navigation timestamp | +| `url` | `string` | | +| `dwell_ms` | `int64` | | + +**`elements_df`** — element-level aggregations: + +| Column | Type | Notes | +|---|---|---| +| `target_desc` | `string` | primary key (with the active URL) | +| `url` | `string` | | +| `n_clicks` | `Int32` | total clicks across all replays | +| `n_unique_users` | `Int32` | distinct `distinct_id` count | +| `n_unique_replays` | `Int32` | distinct `replay_id` count | +| `n_dead_clicks` | `Int32` | | +| `n_rage_clicks` | `Int32` | | +| `mean_dwell_after_ms` | `float64` | average time until next action after clicking this element | + +**`transitions_df`** — page-to-page transitions across all replays: + +| Column | Type | Notes | +|---|---|---| +| `from_url` | `string` | | +| `to_url` | `string` | | +| `count` | `Int32` | | +| `n_unique_replays` | `Int32` | | +| `mean_dwell_s` | `float64` | average dwell on `from_url` before transitioning | + +### Graph projections + +**`page_graph`** — `networkx.DiGraph`: +- Nodes: URL strings +- Node attributes: `n_visits`, `n_unique_replays`, `is_entry`, `is_exit` +- Edges: directed transitions +- Edge attributes: `count`, `n_unique_replays`, `mean_dwell_s` +- Algorithms that work out of the box: `nx.pagerank` (most-visited pages weighted by traffic), `nx.betweenness_centrality(weight="count")` (bottleneck pages), `nx.simple_cycles` (loops in navigation), `nx.shortest_path` (typical user trajectory). + +**`element_graph`** — `networkx.DiGraph`: +- Nodes: `(target_desc, url)` tuples +- Node attributes: `n_clicks`, `n_unique_users` +- Edges: directed sequence of clicks (X → Y when a user clicked X then Y within the same replay) +- Edge attributes: `count`, `mean_gap_s` +- Useful for finding interaction clusters and common click sequences. + +**`path_tree`** — `anytree.AnyNode`: +- Root: synthetic "Start" node +- Children: action sequences across the bundle, with frequency counts on each node +- Methods inherited from anytree: `RenderTree`, `findall`, `UniqueDotExporter` for Graphviz. +- Matches the `FlowQueryResult.anytree` pattern. + +### Event log for process mining + +```python +bundle.event_log(label_fn=None) +``` + +When `pm4py` is **not** installed: returns a `pandas.DataFrame` with columns renamed for the XES standard: + +| Column | Original | Notes | +|---|---|---| +| `case:concept:name` | `replay_id` | pm4py-canonical case ID column | +| `concept:name` | `label_fn(action)` or default | activity label | +| `time:timestamp` | `t` | datetime64[ns, UTC] | + +When `pm4py` **is** installed: returns the same DataFrame (pm4py 2.7+ uses DataFrames as primary citizens, per their docs) and additionally registers it as an `EventLog` via `pm4py.format_dataframe()`. The returned object is usable directly with `pm4py.discover_petri_net_inductive()` etc. + +### Label functions + +```python +# mixpanel_headless._internal.replays.labels + +def default_label_fn(action: UserAction) -> str: + """Default activity label: '{action}:{tag_name}@{normalized_url}'. + + Coarse enough to align across sessions, specific enough to be meaningful + for process mining. Strips query strings from URLs; normalizes path + parameters (numeric IDs replaced with ':id'). + """ + +def selector_label_fn(attr: str = "data-testid") -> Callable[[UserAction], str]: + """Returns a label_fn that uses a stable selector attribute when present. + + Best practice for projects that tag interactive elements: + bundle.event_log(label_fn=selector_label_fn("data-testid")) + + Falls back to default_label_fn when the attribute is missing. + """ + +def url_normalizer(url: str) -> str: + """Strip query strings; replace numeric path segments with ':id'. + + /users/12345/profile → /users/:id/profile + /products?ref=email → /products + """ +``` + +--- + +## Endpoints used + +### Insights Query (existing in headless) + +```http +POST /api/query/insights +Content-Type: application/json +Authorization: Bearer + +{ + "bookmark": { ... InsightsBookmarkParams ... }, + "project_id": , + "workspace_id": +} +``` + +Already wrapped by `Workspace.query()`. No new endpoint binding needed. + +### Replays sign (new, undocumented but used by Mixpanel MCP) + +```http +POST /app/projects//replays/sign/bulk +Content-Type: application/json +Authorization: Bearer + +{ + "replays": [ + {"replay_id": "...", "replay_env": "prod"}, + ... + ] +} +``` + +Response: + +```json +{ + "results": [ + { + "replay_id": "...", + "url": "https://cdn.mxpnl.com/srr-{us|eu|in}/{sha256(replay_id)}-{project_id}/", + "query_string": "URLPrefix=...&Expires=...&KeyName=...&Signature=..." + }, + ... + ] +} +``` + +Auth: standard project-scoped (OAuth bearer or Service Account). Gated by `SESSION_RECORDING_SENSITIVE_DATA` project flag → 403 with `"Your project has sensitive replay data..."` message. Signature TTL: 5 minutes. + +The single-variant endpoint `POST /app/projects//replays/sign` is **not** wrapped — the bulk endpoint with a one-element list covers it, and reducing surface area is preferred. + +### CDN (Cloud CDN signed URL) + +```http +GET https://cdn.mxpnl.com/srr-//-.json? +``` + +No auth header — the query string IS the credential. Returns a JSON array of rrweb events; 404 signals end of files. + +--- + +## Security model + +**Trust boundary**: the library never sees the HMAC signing key. Signing happens server-side at Mixpanel; the library presents standard OAuth/Service-Account credentials to `/replays/sign[/bulk]` and receives presigned URLs in response. Identical model to Cloud Storage / S3 presigned URLs. + +**Bearer-credential handling**: the returned `query_string` IS a bearer credential for ~5 minutes. Anyone in possession of `url + query_string` can read the replay until the signature expires. The library's job is to keep that credential out of incidental contexts: + +| Surface | Treatment | +|---|---| +| `SignedReplay.__repr__` | masks `query_string` as `""` | +| `SignedReplay.__str__` | same masking | +| `mp replays sign` default output | redacted (URL prefix only); `--reveal-signed-urls` opts in with stderr warning | +| Library logging (any level) | no log statement includes `query_string`; URL prefix is logged at DEBUG only | +| Exception `details` dicts | URL prefix only, never query_string | +| Pickling / dataclass `asdict` | full value preserved (the user serializing has chosen to; we can't prevent it) | +| `mixpanel_headless.types.SignedReplay.to_dict()` | full value preserved with a top-level `_warning` key flagging the bearer nature | + +**Coding-agent-specific concern**: agents often paste tool outputs into LLM transcripts and other logging pipelines. The default `__repr__` masking means an agent that prints `sign_replays(...)` output for reasoning context does not leak the bearer credential. The `--reveal-signed-urls` flag and explicit `.query_string` field access are the opt-in escape valves. + +**Sensitive-data gating**: server-side enforcement of the `SESSION_RECORDING_SENSITIVE_DATA` project flag returns 403. The library translates to `SessionReplayAccessError` with structured `details = {"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA"}` and an actionable message naming the permission required. + +**Open-source library specifically**: the library being open-source changes nothing in the security model. No secrets ship with the library; trust flows through the user's authenticated session. The signed-URL pattern is industry-standard. + +--- + +## Performance and limits + +| Operation | Target | Notes | +|---|---|---| +| `list_replays(user, 30d)` | ≤ 1 RTT | Single Insights query | +| `sign_replays(ids)` | ≤ 1 RTT | Bulk endpoint; no documented cap, MCP uses 20 for LLM context; headless can comfortably batch 100+ | +| `fetch_replay` (30 MB replay) | ≤ 5 s | Concurrent file fetch, 50-wide batches | +| `stream_replay` first-event latency | ≤ 1 s | Sign + first batch of files | +| `Replay.actions_df` materialization | ≤ 200 ms | Linear in rrweb event count; analyzer is the hot path | +| `ReplayBundle.actions_df` materialization | linear in `sum(n_actions)` | Cached after first access | +| `fetch_replays(N replays)` | parallel | Outer concurrency 4, inner concurrency 50 | +| pm4py inductive discovery | seconds for N≤1000 replays | bottleneck is process tree construction | + +**No cap on bundle size in headless**. The MCP server's `MCP_MAX_REPLAYS_TO_PROCESS = 20` is an LLM-context-window concern, not a technical limit. `mixpanel-headless` users with 10,000 replays should be able to materialize a bundle, paying memory linearly. Document the rough memory budget (~2 MB per replay in `actions_df` at typical density). + +**Streaming vs buffering tradeoff (documented)**: +- `fetch_replay` buffers all events. Memory ~ replay size (10–500 MB typical). +- `stream_replay` yields events incrementally with bounded memory (one batch of 50 files at a time, ~5–50 MB peak). +- The analyzer requires the full stream (DOM state propagation), so `analyze_replay` builds on `fetch_replay`, not `stream_replay`. + +**CDN concurrency tuning**: the existing MCP fetcher uses `batch_size=50`. We adopt the same default. Expose `cdn_concurrency` parameter on `fetch_replay` for users on slow connections who want to lower it. + +--- + +## Optional dependencies + +`pyproject.toml` adds: + +```toml +[project.optional-dependencies] +# ... existing extras ... + +replay-mining = ["pm4py>=2.7"] +replay-ml = ["tslearn>=0.6"] +replay-all = ["mixpanel-headless[replay-mining,replay-ml]", "networkx>=3", "anytree>=2"] +``` + +The vendored rrweb analyzer ships in core (no extra needed) — it's pure-stdlib Python and adds no install weight. + +`networkx` and `anytree` are reused from the existing flow-query optional extras (they are already optional deps of `mixpanel-headless` via `[flows]` or similar). + +### Lazy import pattern + +Every property that touches an optional dep follows this pattern: + +```python +@property +def page_graph(self) -> "networkx.DiGraph": + if self._page_graph_cache is not None: + return self._page_graph_cache + try: + import networkx as nx + except ImportError as e: + raise ImportError( + "ReplayBundle.page_graph requires networkx. " + "Install with: pip install 'mixpanel-headless[replay-all]'" + ) from e + # ... build graph ... + object.__setattr__(self, "_page_graph_cache", graph) + return graph +``` + +This matches the existing `FlowQueryResult.graph` pattern. + +--- + +## File layout + +### New files (Phase 1) + +``` +src/mixpanel_headless/ +├── _internal/ +│ └── services/ +│ └── replays.py # ReplaysService — orchestrates sign/fetch/discovery +│ +├── workspace.py # MODIFIED — add 9 new methods +├── types.py # MODIFIED — add ReplaySummary, SignedReplay, ReplayEvent +├── exceptions.py # MODIFIED — add SessionReplayError hierarchy +├── __init__.py # MODIFIED — add new exports +│ +└── cli/ + ├── main.py # MODIFIED — register replays_app + └── commands/ + └── replays.py # NEW — Typer commands: list, events, sign, fetch + +tests/ +├── unit/ +│ ├── test_replays_service.py # NEW — ReplaysService unit tests (mocked HTTP) +│ ├── test_types_replay_summary.py # NEW — dataclass shape +│ ├── test_types_signed_replay.py # NEW — __repr__ masking, expires_at logic +│ └── test_workspace_replays.py # NEW — Workspace method tests +├── pbt/ +│ └── test_cdn_walker_pbt.py # NEW — file-numbering walker invariants +├── integration/ +│ └── test_replays_live.py # NEW — live-marked: list, sign, fetch a known replay +└── fixtures/ + └── rrweb/ + └── sample-replay-001.json # NEW — recorded rrweb event stream for parsing tests +``` + +### New files (Phase 2 — analyzer + ReplayBundle) + +``` +src/mixpanel_headless/ +├── _internal/ +│ └── replays/ # NEW SUBPACKAGE +│ ├── __init__.py +│ ├── rrweb_analyzer.py # VENDORED from analytics/backend/replays/rrweb_analyzer.py +│ ├── labels.py # NEW — default_label_fn, selector_label_fn, url_normalizer +│ └── aggregators.py # NEW — top_paths, dead_clicks, rage_clicks, etc. +│ +├── workspace.py # MODIFIED — add fetch_replay, stream_replay, fetch_replays, +│ # replays_for_user, analyze_replay +└── types.py # MODIFIED — add Replay, ReplayBundle, UserAction + +tests/ +├── unit/ +│ ├── test_rrweb_analyzer.py # PORTED from analytics/backend/replays/test_rrweb_analyzer.py +│ ├── test_replay_labels.py # NEW — default + selector label stability +│ ├── test_types_replay.py # NEW — Replay DataFrame projections +│ └── test_types_replay_bundle.py # NEW — ReplayBundle aggregations, filters +├── pbt/ +│ ├── test_replay_labels_pbt.py # NEW — label_fn stability across DOM perturbations +│ └── test_types_replay_bundle_pbt.py # NEW — DataFrame projection invariants +└── fixtures/ + └── rrweb/ + ├── sample-replay-002.json # multi-page replay + ├── sample-replay-003.json # replay with errors and rage clicks + └── sample-bundle-fixture.py # builds a deterministic 10-replay ReplayBundle +``` + +### New files (Phase 3 — pm4py + tslearn) + +``` +src/mixpanel_headless/ +├── _internal/ +│ └── replays/ +│ ├── pm4py_adapter.py # NEW — event_log() pm4py wrapping +│ └── ml_adapter.py # NEW — cluster() using tslearn DTW + +tests/ +├── unit/ +│ ├── test_pm4py_adapter.py # NEW — gated on pm4py install marker +│ └── test_ml_adapter.py # NEW — gated on tslearn install marker +``` + +### Modified files (cross-phase summary) + +| File | Phase | Change | +|---|---|---| +| `workspace.py` | 1, 2 | +9 methods (Phase 1: 4; Phase 2: 5) | +| `types.py` | 1, 2 | +6 dataclasses (Phase 1: 3; Phase 2: 3) | +| `exceptions.py` | 1 | +4 exception classes | +| `__init__.py` | 1, 2 | +10 exports | +| `cli/main.py` | 1 | +1 group registration | +| `pyproject.toml` | 3 | +2 optional-dependencies groups | + +--- + +## Test strategy + +### Unit tests + +- **`test_replays_service.py`**: mocked `MixpanelAPIClient`. Verify `/replays/sign/bulk` request body shape, error mapping (403 → `SessionReplayAccessError`, 404 → `ReplayNotFoundError`, etc.), retry behavior. +- **`test_types_signed_replay.py`**: `__repr__` redaction, `expires_at` arithmetic, `is_expired` boundary. +- **`test_workspace_replays.py`**: mocked service + API client. Verify `list_replays` query shape (uses `query()`, groups on `$mp_replay_id` + `$mp_replay_retention_period`), validates argument combinations (distinct_id XOR replay_ids), error propagation. +- **`test_rrweb_analyzer.py`**: ported from `analytics/backend/replays/test_rrweb_analyzer.py`. Covers DOM tracker invariants, all incremental sources, debounce behavior, markdown output format. +- **`test_types_replay.py`** and **`test_types_replay_bundle.py`**: DataFrame projection columns, cache behavior, mode-aware `df` selection, `.to_dict()` serializability, `__repr__` shape. +- **`test_replay_labels.py`**: default and selector label stability across DOM perturbations; URL normalizer round-tripping. + +### Property-based tests + +- **`test_cdn_walker_pbt.py`**: invariants for the file-numbering walker. Given arbitrary 404 positions, the walker terminates correctly; never re-fetches a 404; respects `max_files`. +- **`test_replay_labels_pbt.py`**: label stability across DOM perturbations (Hypothesis generates trees with random attribute drift, label_fn outputs must match for semantically-equivalent elements). +- **`test_types_replay_bundle_pbt.py`**: DataFrame projection invariants. Given an arbitrary bundle (Hypothesis-generated list of `Replay` instances with random action streams): + - `sessions_df` has exactly `len(replays)` rows. + - `actions_df.groupby("replay_id").size().sum() == sum(len(r.actions) for r in bundle.replays)`. + - `bundle.filter(predicate).replays` is a subset of `bundle.replays`. + - `bundle.where(distinct_id=x).replays` is the same as `bundle.filter(lambda r: r.distinct_id == x).replays`. + - `bundle.head(n)` returns at most `n` replays. + +### Integration tests + +- **`test_replays_live.py`** (marked `@pytest.mark.live`): against a real project with known replays. Tests: + - `list_replays` returns at least one replay for a known active user. + - `sign_replays` returns valid signed URLs. + - A CDN fetch of the signed URL returns at least one rrweb event. + - `analyze_replay` produces non-empty markdown output. + - Sensitive-data 403 path tested against a sensitivity-flagged fixture project (if one exists). + +### Mutation testing + +`just mutate` targets: +- `src/mixpanel_headless/_internal/services/replays.py` +- `src/mixpanel_headless/_internal/replays/rrweb_analyzer.py` +- `src/mixpanel_headless/_internal/replays/labels.py` +- `src/mixpanel_headless/_internal/replays/aggregators.py` + +Target: 80%+ mutation score. + +### Fixture strategy + +Three sample rrweb event streams checked into `tests/fixtures/rrweb/`: +- **sample-replay-001.json**: minimal — login + one click + navigation. +- **sample-replay-002.json**: multi-page — 5+ navigations, mixed interactions. +- **sample-replay-003.json**: pathological — console errors, rage clicks, dead clicks, long pauses. + +A Python fixture builder at `tests/fixtures/rrweb/sample-bundle-fixture.py` constructs a deterministic 10-replay `ReplayBundle` for bundle-level tests. + +Signed-URL handling is tested with mocked HTTP; real CDN fetches happen only in the live-marked integration tests. + +--- + +## Phase plan + +### Phase 1 — Discovery + Signed Access + `Replay` (1 PR, ~1,200 LoC) + +**Ships**: +- `ReplaysService` orchestrating discovery + signing + fetching +- `Workspace.list_replays`, `sign_replays`, `sign_replay`, `events_for_replay`, `events_for_replays`, `fetch_replay`, `stream_replay` +- `ReplaySummary`, `SignedReplay`, `ReplayEvent`, `Replay` (with `events_df`, `mixpanel_df`, `pages_df`, `to_rrweb_player_json`) — note: `actions_df` requires the analyzer, so `Replay.actions` is empty in Phase 1; the analyzer arrives in Phase 2 +- `SessionReplayError` hierarchy +- `mp replays {list, events, sign, fetch}` CLI +- Unit + PBT + integration test coverage to 90%+ + +**Does not ship**: vendored analyzer, `ReplayBundle`, action-level aggregations, graphs/trees, pm4py, tslearn. + +**Why ship-able alone**: gives users the building blocks (signed URLs, raw rrweb streams) immediately. Users who want analysis can layer their own or wait for Phase 2. + +**Estimated effort**: 1 week. + +### Phase 2 — Vendored Analyzer + `ReplayBundle` (1 PR, ~1,500 LoC) + +**Ships**: +- Vendored `rrweb_analyzer.py` at `_internal/replays/rrweb_analyzer.py` with full DOM tracker + event analyzer + markdown reporter +- `labels.py` with `default_label_fn`, `selector_label_fn`, `url_normalizer` +- `aggregators.py` with `top_paths`, `top_clicks`, `top_pages`, `dead_clicks`, `rage_clicks`, `long_pauses` +- `UserAction`, `Replay.actions_df`, `Replay.summary_markdown` populated by the analyzer +- `ReplayBundle` with all 7 DataFrame projections (`sessions_df`, `actions_df`, `events_df`, `mixpanel_df`, `pages_df`, `elements_df`, `transitions_df`) +- `ReplayBundle.page_graph`, `element_graph`, `path_tree` (lazy `networkx` + `anytree`) +- `ReplayBundle.filter`, `where`, `find_pattern`, `error_sessions`, `head`, `sample`, `join_mixpanel_events`, `summary_markdown`, `compare` +- `Workspace.fetch_replays`, `replays_for_user`, `analyze_replay` +- `mp replays analyze` and `mp replays for-user` CLI +- Unit + PBT + integration test coverage to 90%+; mutation score 80%+ on analyzer + +**Estimated effort**: 1.5 weeks. + +### Phase 3 — `pm4py` + `tslearn` extras (1 PR, ~500 LoC) + +**Ships**: +- `pm4py_adapter.py` powering `ReplayBundle.event_log()` (returns DataFrame when pm4py is absent, pm4py-formatted DataFrame when present) +- `ml_adapter.py` powering `ReplayBundle.cluster(n, features)` (uses `tslearn.clustering.TimeSeriesKMeans` with DTW metric) +- Optional extras `replay-mining`, `replay-ml`, `replay-all` in `pyproject.toml` +- Unit tests gated on install markers +- A documentation page showing the pm4py integration end-to-end (BPMN discovery, conformance, variant analysis) + +**Decision gate**: ship if there's user demand, otherwise defer indefinitely. + +**Estimated effort**: 3-4 days. + +### Phase 4 — Mobile session replays (future) + +Out of scope for now. The discovery layer already works for mobile (`$mp_session_record` + `$mp_replay_id` are platform-agnostic), but the bytes layer and analyzer assume rrweb. Mobile uses a different format. Track at SR-230 in the analytics monorepo for upstream parity. + +--- + +## Open questions and risks + +### Endpoint stability + +The `/replays/sign[/bulk]` endpoint is not in the public API reference but is used by Mixpanel's own MCP server. `mixpanel-headless` is now an official second client to mixpanel.com and is already at near-parity on undocumented API usage. Risk is real but bounded; the pre-release version notice already warns of API changes. + +**Mitigation**: clear `CHANGELOG.md` entry noting the endpoint dependency. If the endpoint ever changes shape, the surface in `mixpanel-headless` is small (a single service file) and easy to update. + +### Action labeling for process mining + +The default `f"{action}:{tag_name}@{normalized_url}"` label is a starting point. Real-world replays may exhibit: + +- DOM drift (i18n, A/B tests, dynamic content) producing different labels for semantically-identical actions. +- Granularity mismatches (per-button vs per-page-area vs per-feature). + +**Mitigation**: ship the `label_fn=` escape valve from day one. Document the recommended SDK practice of tagging interactive elements with `data-testid`. Provide `selector_label_fn` as a one-liner for projects that do. + +### Retention period edge cases + +`$mp_replay_retention_period` is stamped at ingestion time. Older replays (pre-feature) won't have it. Orgs that change retention plans will have replays with different values. + +**Mitigation**: when the property is missing, default to 30 days with a structured warning that includes the replay_id and a hint to upgrade Mixpanel SDK versions on the client side. + +### Bundle memory budget + +10,000 replays at ~2 MB each = ~20 GB just for raw rrweb events. Realistic bundle sizes: + +- Single user, single month: ~10–50 replays — trivial +- All users for a feature, single week: ~500–5,000 replays — manageable with `actions_df` only +- All replays for a project for a quarter: 100k+ — needs streaming, not bundles + +**Mitigation**: document the memory budget. `ReplayBundle` is for hundreds, not millions. For larger analysis, suggest `stream_replay` per replay + incremental aggregation. + +### Concurrent CDN fetches + +Mixpanel's CDN has rate limits we haven't characterized. The MCP server uses concurrency 50 internally. + +**Mitigation**: adopt the same default; expose `cdn_concurrency` parameter for tuning; add a 429-aware retry in the CDN fetcher (already in `MixpanelAPIClient`). + +### Signed URL expiration mid-stream + +A slow consumer of `stream_replay` could outlive the 5-minute signature. + +**Mitigation**: `re_sign_on_expiry=True` (default) catches the 403, re-signs, and continues. Caller can disable for tighter control. + +### Backwards compatibility with future MCP analyzer changes + +The MCP analyzer has open tickets (SR-229 pagination, SR-230 mobile). When upstream changes, our vendored copy will drift. + +**Mitigation**: explicitly mark the analyzer as vendored with a source link in the module docstring; add a CI job (or docstring TODO) to periodically diff against upstream. + +### sklearn / scipy install footprint + +`tslearn` depends on `numpy`, `scipy`, `scikit-learn`, `joblib`. That's a heavy install. + +**Mitigation**: extra is opt-in. Users who want clustering accept the install weight. + +--- + +## Documentation + +### `help.py` updates + +Add Replay-related entries to the live documentation system so `python help.py Replay` and `python help.py ReplayBundle` work. + +### `mixpanelyst` skill updates + +Add a section on session replay analysis to the auto-triggered analytics skill. Example queries: + +- "Show me what user X did in the last week" +- "Find all sessions where users clicked the upgrade button but didn't complete checkout" +- "What's the most common path users take through onboarding?" + +### `dashboard-expert` skill + +No changes — dashboards don't currently embed replays. + +### New skill: `replay-analyst` (optional, Phase 2 or 3) + +A purpose-built skill that auto-triggers on questions like "show me a replay", "analyze user behavior in this session", "what are users doing in feature X". Composes `replays_for_user`, `analyze_replay`, and `ReplayBundle` aggregations into LLM-friendly outputs. + +### Plugin command `mixpanel-headless:replays` + +A slash command for the `mixpanel-headless` Claude Code plugin: `/mixpanel-headless:replays USER [--from DATE] [--to DATE]` produces a markdown summary of a user's recent activity. + +--- + +## References + +### Internal source (analytics monorepo) + +- `mcp_server/tools/replays.py` — MCP `Get-User-Replays-Data` tool definition +- `mcp_server/api/replays.py` — `ReplaysService` (the orchestration we're paralleling) +- `mcp_server/api/utils/replays.py` — `ReplayEvent` type +- `backend/replays/rrweb_analyzer.py` — analyzer to vendor (~600 LoC) +- `backend/replays/cdn_fetcher.py` — CDN walker pattern +- `backend/replays/query_utils.py` — events-in-window query builder (we re-implement via `Workspace.query()`) +- `backend/replays/constants.py` — GCS bucket map, MCP cap, retention defaults +- `webapp/app_api/projects/replays/views.py` — endpoint implementation, signing logic, sensitive-data gate +- `webapp/app_api/projects/replays/urls.py` — route definitions +- `webapp/app_api/projects/replays/utils.py` — `get_replay_gcs_prefix` +- `go/src/mixpanel.com/ingestion/api/handlers/record_session.go` — source of truth for `$mp_replay_retention_period` and CDN file naming format + +### Public documentation + +- [Session Replay overview](https://docs.mixpanel.com/docs/session-replay) +- [JavaScript SDK replay docs](https://docs.mixpanel.com/docs/tracking-methods/sdks/javascript/javascript-replay) +- [iOS SDK replay docs](https://docs.mixpanel.com/docs/tracking-methods/sdks/swift/swift-replay) +- [Android SDK replay docs](https://docs.mixpanel.com/docs/tracking-methods/sdks/android/android-replay) +- [Session Replay Privacy Controls](https://docs.mixpanel.com/docs/session-replay/session-replay-privacy-controls) + +### Third-party libraries + +- [rrweb event types](https://github.com/rrweb-io/rrweb/blob/master/packages/types/src/index.ts) — `EventType` and `IncrementalSource` enums +- [rrweb event recipe](https://rrweb.com/docs/recipes/dive-into-event) — event-shape walkthrough +- [pm4py documentation](https://pm4py.fit.fraunhofer.de/documentation) — process mining library +- [pm4py `format_dataframe`](https://pm4py.fit.fraunhofer.de/static/assets/api/2.7.8/pm4py.html) — DataFrame-to-EventLog adapter +- [pm4py `discover_petri_net_inductive`](https://pm4py.fit.fraunhofer.de/static/assets/api/2.7.11/generated/pm4py.discovery.discover_petri_net_inductive.html) — inductive miner +- [tslearn documentation](https://tslearn.readthedocs.io/) — time series clustering with DTW +- [networkx](https://networkx.org/) — graph algorithms (already a `mixpanel-headless` optional dep) +- [anytree](https://anytree.readthedocs.io/) — tree algorithms (already a `mixpanel-headless` optional dep) + +### Headless precedent + +- `src/mixpanel_headless/types.py:11434` — `FlowQueryResult` (the pattern we're matching) +- `src/mixpanel_headless/types.py:11073` — `FlowTreeNode` (tree projection pattern) +- `src/mixpanel_headless/workspace.py:2321` — `Workspace.query()` (the typed Insights API to use for discovery) +- `src/mixpanel_headless/workspace.py:3888` — `Workspace.query_flow()` (similar high-leverage query method) +- `src/mixpanel_headless/workspace.py:1246` — `Workspace.stream_events()` (streaming convention) +- `src/mixpanel_headless/_internal/services/` — service layer where `replays.py` lives +- `src/mixpanel_headless/cli/commands/cohorts.py` — CLI command pattern to mirror +- `specs/034-flow-query/plan.md` — closest spec precedent for shape and detail + +### Research artifacts + +- [Initial design report (2026-05-27)](https://storage.googleapis.com/jared-shares/2026-05/mixpanel-headless-session-replay-design.html) diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md index 97bae7ac..3d5ab002 100644 --- a/docs/api/exceptions.md +++ b/docs/api/exceptions.md @@ -210,3 +210,33 @@ The `details` dict carries `length` (the actual content length) and `max` (the c show_root_heading: true show_root_toc_entry: true +## Session Replay Exceptions + +Raised by the session-replay surface (`fetch_replay()`, `sign_replay()`, `stream_replay()`). `SessionReplayError` is the base — catch it to handle any replay failure. See the [Session Replay guide](../guide/session-replay.md). + +| Exception | Raised When | +|-----------|-------------| +| `SessionReplayAccessError` | The project has the `SESSION_RECORDING_SENSITIVE_DATA` flag set and the caller lacks the `sensitive_data_replay` permission (HTTP 403). `details` carries `project_id`, `flag`, and `permission_required`. | +| `SignedURLExpiredError` | A signed CDN URL expired mid-fetch (~5-minute TTL) and re-signing was disabled or also failed. | +| `ReplayNotFoundError` | The replay's first CDN file returned 404 — it aged out of its retention window, was never recorded, or was deleted. | + +::: mixpanel_headless.SessionReplayError + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.SessionReplayAccessError + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.SignedURLExpiredError + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.ReplayNotFoundError + options: + show_root_heading: true + show_root_toc_entry: true + diff --git a/docs/api/types.md b/docs/api/types.md index 93ee9517..e7e77cdd 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -202,6 +202,55 @@ Types for `Workspace.query_flow()` — typed flow path analysis with step defini show_root_heading: true show_root_toc_entry: true +## Session Replay Types + +Types for the session-replay surface — `Workspace.replays_for_user()`, `fetch_replay()`, `fetch_replays()`, and the `mp replays` CLI. See the [Session Replay guide](../guide/session-replay.md). `Replay` is conceptually a `ReplayBundle` of size one; both expose the same DataFrame projections. + +::: mixpanel_headless.ReplaySummary + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.SignedReplay + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.ReplayEvent + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.UserAction + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.Replay + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.ReplayBundle + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.default_label_fn + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.selector_label_fn + options: + show_root_heading: true + show_root_toc_entry: true + +::: mixpanel_headless.url_normalizer + options: + show_root_heading: true + show_root_toc_entry: true + ## Legacy Query Results ::: mixpanel_headless.SegmentationResult diff --git a/docs/api/workspace.md b/docs/api/workspace.md index bf96cf3b..3b4563a3 100644 --- a/docs/api/workspace.md +++ b/docs/api/workspace.md @@ -19,6 +19,7 @@ Workspace orchestrates internal services and provides direct App API access: - **Operational Tooling** — Manage alerts, annotations, and webhooks via Mixpanel App API (workspace-scoped) - **Data Governance** — Manage Lexicon definitions, drop filters, custom properties, custom events, lookup tables, schema registry, schema enforcement, data auditing, volume anomalies, and event deletion requests via Mixpanel App API (workspace-scoped) - **Business Context** — Read and write the markdown documentation that grounds AI assistants (org and project scopes, 50,000-char cap) +- **Session Replay** — Discover, sign, fetch, and analyze rrweb session recordings; project them into session-level DataFrames and an LLM-friendly action timeline ## Key Features @@ -143,6 +144,28 @@ Project-scope writes require `edit_project_info` permission; org-scope writes re !!! note "`workspaces()` vs `list_workspaces()`" Both methods are exposed. `workspaces()` (recommended) returns `list[WorkspaceRef]` from the cached `/me` response — fast, typed, and consistent with `events()` / `properties()` / `funnels()` / `cohorts()`. `list_workspaces()` is a lower-level escape hatch that calls `GET /api/app/projects/{pid}/workspaces/public` directly and returns `list[PublicWorkspace]`. +### Session Replay + +Discover a user's rrweb session recordings, fetch the raw event stream from the signed CDN, and project them into analysis-ready DataFrames. The methods: `list_replays`, `events_for_replay` / `events_for_replays`, `sign_replay` / `sign_replays`, `fetch_replay` / `fetch_replays`, `stream_replay`, `replays_for_user`, and `analyze_replay`. + +```python +import mixpanel_headless as mp + +ws = mp.Workspace() + +# Discovery + fetch + Mixpanel-event join in one call → a ReplayBundle +bundle = ws.replays_for_user("user-42", from_date="2025-01-01", to_date="2025-01-31") +print(bundle.sessions_df) # one row per session: duration, n_clicks, n_errors +print(bundle.top_clicks(10)) # most-clicked elements (focus interactions excluded) +print(bundle.replays[0].summary_markdown) # LLM-friendly action timeline + +# Single replay, with the raw rrweb stream for the JS player +replay = ws.fetch_replay("0190ebde-d50d-71b1-804c-ec1b4a533ef9") +player_json = replay.to_rrweb_player_json() +``` + +Signed CDN URLs are bearer credentials: `SignedReplay` masks them in `repr` / `str` and the library never logs them. A `SESSION_RECORDING_SENSITIVE_DATA` 403 raises `SessionReplayAccessError`. See the [Session Replay guide](../guide/session-replay.md) for the full surface, the `mp replays` CLI, and the DataFrame schemas. + ## In-Session Switching `Workspace.use()` swaps the active account, project, workspace, or target without rebuilding the underlying `httpx.Client` or per-account `/me` cache. It returns `self` for fluent chaining, so cross-project iteration is O(1) per swap. diff --git a/docs/guide/session-replay.md b/docs/guide/session-replay.md new file mode 100644 index 00000000..98043b59 --- /dev/null +++ b/docs/guide/session-replay.md @@ -0,0 +1,235 @@ +# Session Replay + +Discover a user's [Mixpanel Session Replay](https://docs.mixpanel.com/docs/session-replay) recordings, fetch the raw rrweb event stream from the signed CDN, and project the sessions into analysis-ready pandas DataFrames plus an LLM-friendly action timeline — all without leaving Python or the shell. + +!!! tip "The high-leverage type is `ReplayBundle`" + A `ReplayBundle` is a collection of replays with cross-session projections. A single `Replay` is conceptually a bundle of size one, and the API treats them the same way — every DataFrame projection available on a bundle is available on a replay. + +## When to Use It + +Session replay answers "what did this user actually *do*?" — the click-by-click story behind an analytics number. Reach for it when you need to: + +- Pull a specific user's recent sessions and read the timeline (`replays_for_user`). +- Correlate a tracked Mixpanel event with the on-screen actions around it (`include_mixpanel_events`). +- Rank the most-clicked elements, find rage-click bursts, or surface sessions with console errors across many replays. +- Export the raw rrweb stream to feed Mixpanel's JS player or your own tooling (`to_rrweb_player_json`). + +The surface is built on the same signed-CDN endpoints Mixpanel's own MCP server uses. It does **not** persist anything to disk — signed URLs are time-bounded bearer credentials handled in process. + +## Getting Started + +The one-call path — discover a user's replays, fetch them, and join the Mixpanel events that fired during each session: + +```python +import mixpanel_headless as mp + +ws = mp.Workspace() + +bundle = ws.replays_for_user( + "user-42", + from_date="2025-01-01", + to_date="2025-01-31", +) + +# One row per session: duration, action/click/error counts, entry/exit URL +print(bundle.sessions_df) + +# The LLM-friendly action timeline for the first replay +print(bundle.replays[0].summary_markdown) +``` + +`replays_for_user` defaults `limit=20` (each replay materializes its full byte stream, so fetching is byte-heavy) and `include_mixpanel_events=True`. Raise `limit` deliberately, or drop to `list_replays` + `stream_replay` for large sweeps. + +## Discovery + +`list_replays` issues a single Insights query against `$mp_session_record` and returns lightweight `ReplaySummary` handles (no bytes fetched). Discover by user and date window, or hydrate an explicit list of IDs: + +```python +# By user (from_date / to_date required) +summaries = ws.list_replays( + distinct_id="user-42", + from_date="2025-01-01", + to_date="2025-01-31", + limit=100, +) +for s in summaries: + print(s.replay_id, s.start_time, s.retention_days) + +# Or hydrate explicit replay IDs (no distinct_id needed) +summaries = ws.list_replays(replay_ids=["0190ebde-d50d-71b1-804c-ec1b4a533ef9"]) +``` + +An empty window returns an empty list — it never raises. Each summary carries the per-replay `retention_days` (read from `$mp_replay_retention_period`, defaulting to 30 with a warning when the property is absent). + +## Fetching a Single Replay + +`fetch_replay` signs the replay, walks the CDN files in parallel, runs the vendored rrweb analyzer, and returns a fully-materialized `Replay`: + +```python +replay = ws.fetch_replay( + "0190ebde-d50d-71b1-804c-ec1b4a533ef9", + include_mixpanel_events=True, # optional Mixpanel-event join +) + +print(replay.duration_seconds) # 2769.0 +print(len(replay.rrweb_events)) # raw rrweb events +print(replay.summary_markdown) # action timeline +print(replay.page_path()) # navigation URL sequence + +# Raw rrweb JSON, timestamp-sorted, ready for the rrweb JS player +player_json = replay.to_rrweb_player_json() +``` + +Pass `retention_days=` to skip the retention-discovery round trip when you already know it, and `distinct_id=` to stamp the owning user onto the returned `Replay` (`replays_for_user` does this for you). + +### Streaming large recordings + +For long sessions where you don't want the whole byte stream in memory at once, `stream_replay` yields rrweb events one batch at a time and re-signs transparently if the URL expires mid-walk: + +```python +for event in ws.stream_replay("0190ebde-d50d-71b1-804c-ec1b4a533ef9"): + process(event) +``` + +## DataFrame Projections + +A `ReplayBundle` (and a single `Replay`) exposes long-format projections keyed by `replay_id`. `bundle.df` defaults to `sessions_df`. + +| Projection | Grain | Key columns | +|---|---|---| +| `sessions_df` | one row per replay | `replay_id`, `distinct_id`, `start_time`, `end_time`, `duration_s`, `retention_days`, `n_events`, `n_actions`, `n_clicks`, `n_inputs`, `n_pages`, `n_errors`, `n_mp_events`, `entry_url`, `exit_url` | +| `actions_df` | one row per normalized action | `replay_id`, `t`, `action`, `target_node_id`, `target_desc`, `description`, `url`, `metadata` | +| `events_df` | one row per raw rrweb event | `replay_id`, `t`, `type`, `source`, `mouse_type`, `target_node_id`, `url`, `raw` | +| `mixpanel_df` | one row per Mixpanel event in the replay window | `replay_id`, `t`, `event_name`, `properties` | +| `elements_df` | one row per `(target_desc, normalized_url)` | `target_desc`, `url`, `n_clicks`, `n_unique_replays` | + +```python +# Feed directly into DuckDB, or any pandas workflow +import duckdb +duckdb.from_df(bundle.actions_df).aggregate("action, count(*)", "action").show() +``` + +The `description` column on `actions_df` is the analyzer's full human-readable phrase (`'Clicked button "Sign in"'`, `'Scrolled'`, `'Console error: …'`); `target_desc` is the bare element label. + +## The Action Timeline + +`summary_markdown` renders a compact, LLM-friendly timeline — one line per action as `{timestamp_seconds}: {description}`, with consecutive duplicate actions collapsed into a `(×N)` suffix so a re-rendering data grid doesn't flood the output: + +```text +1779693081: Navigated to https://app.example.com/boards +1779693457: Focused mp-button "hor-ellipsis" +1779693459: Clicked div in li "Refresh Data" +1779693483: Scrolled (×3) +``` + +`bundle.summary_markdown` concatenates the per-replay timelines with a totals header. The `mp replays analyze` CLI command renders the same output. + +## Aggregations + +Bundle-level aggregations return DataFrames (following the `FlowQueryResult` idiom): + +```python +print(bundle.top_clicks(10)) # target_desc, count — genuine clicks only +print(bundle.rage_clicks(threshold=3, window_ms=1000)) # replay_id, t_start, target_desc, count +print(bundle.long_pauses(threshold_s=10)) # replay_id, t_start, duration_s +err = bundle.error_sessions() # a NEW bundle of only the replays with console errors +``` + +`top_clicks` (and `elements_df`) count **genuine clicks only** — a real user click fires both a `focused` and a `clicked` interaction, and counting both would double every click, so the focus-only interactions are excluded. + +## Filters and Comparison + +Filters return a **new** bundle (immutable semantics) — the original is untouched, so chains stay cheap: + +```python +# Sessions that visited /checkout AND lasted longer than 60s +checkout = ( + bundle + .where(contains_url="/checkout") + .filter(lambda r: r.duration_seconds > 60) +) + +# Deterministic sample for manual review +for r in checkout.sample(n=3, seed=42).replays: + print(r.summary_markdown[:200], "…") + +# Sessions whose action labels contain a contiguous sub-sequence +matched = bundle.find_pattern(["click:button@/", "navigate:…@/checkout"]) + +# Diff action frequencies between two cohorts of sessions +converters = ws.replays_for_user("user-99", from_date="2025-01-01", to_date="2025-01-31") +print(bundle.compare(converters)) # action | self_count | other_count | delta +``` + +`where(...)` accepts `distinct_id`, `contains_url`, `has_event`, `min_duration_s`, and `max_duration_s`. `find_pattern` accepts a `label_fn=` override (see `default_label_fn` / `selector_label_fn`). + +## Correlating Mixpanel Events + +`mixpanel_df` is populated when you fetch with `include_mixpanel_events=True` (the default for `replays_for_user`), or lazily via `join_mixpanel_events()`. It holds the tracked Mixpanel events that fired during each replay's time window — the analytics layer alongside the action layer: + +```python +bundle = ws.replays_for_user( + "user-42", from_date="2025-01-01", to_date="2025-01-31", + event_properties=["$browser", "plan"], # up to 5 extra properties +) +print(bundle.mixpanel_df) # replay_id | t | event_name | properties +``` + +For a single replay or an explicit ID list, use `events_for_replay(replay_id)` / `events_for_replays(replay_ids)`. + +## Signed URLs and Security + +Replay files live behind a time-bounded signed CDN URL (≈5-minute TTL). The query string is a **bearer credential**: + +- `SignedReplay` masks the credential in `repr` and `str`, and the library **never logs it** at any level. +- `sign_replay` / `sign_replays` return the handles; `fetch_replay` signs and fetches in one step. +- A 403 indicating the project's `SESSION_RECORDING_SENSITIVE_DATA` flag raises `SessionReplayAccessError` with the missing permission in `details`. An expired URL raises `SignedURLExpiredError`; a replay absent from the CDN raises `ReplayNotFoundError`. + +```python +signed = ws.sign_replay("0190ebde-d50d-71b1-804c-ec1b4a533ef9") +print(signed) # SignedReplay(replay_id='…', url='…', query_string='', …) +``` + +!!! warning "DOM text can carry PII" + The analyzer's `target_desc` and `description` fields surface text that + rrweb captured from the page — `aria-label`, `title`, `alt`, and visible + element text. If a recorded page rendered personal data (e.g. a + `"Welcome, Jane Doe"` heading), that text lands in `actions_df`, the + markdown timelines, and anything you build from them (logs, files, LLM + context). The library faithfully reflects what rrweb recorded — it does + not scrub content — so treat analyzer output with the same care as the + underlying recording, and rely on Mixpanel's recording-side masking to + keep sensitive fields out of the capture in the first place. + +## CLI + +The `mp replays` command group mirrors the Python surface: + +```bash +# Discover a user's replays (or hydrate explicit --replay-id values) +mp replays list --user user-42 --from 2025-01-01 --to 2025-01-31 + +# Mixpanel events during a replay's window +mp replays events 0190ebde-d50d-71b1-804c-ec1b4a533ef9 + +# Sign for CDN access — redacted by default; --reveal-signed-urls opts in +mp replays sign 0190ebde-d50d-71b1-804c-ec1b4a533ef9 + +# Write the raw rrweb JSON (rrweb-player compatible) +mp replays fetch 0190ebde-d50d-71b1-804c-ec1b4a533ef9 -o replay.json + +# Render the markdown action timeline +mp replays analyze 0190ebde-d50d-71b1-804c-ec1b4a533ef9 + +# Discover + fetch + analyze in one command; write per-replay timelines to a dir +mp replays for-user user-42 --from 2025-01-01 --to 2025-01-31 \ + --include analyze --out-dir ./timelines +``` + +`mp replays sign --reveal-signed-urls` is the single opt-in path to emitting the full credential; it prints a stderr warning on every invocation. + +## See Also + +- [API Reference: Workspace → Session Replay](../api/workspace.md#session-replay) +- [API Reference: Session Replay Types](../api/types.md#session-replay-types) +- [API Reference: Session Replay Exceptions](../api/exceptions.md#session-replay-exceptions) diff --git a/docs/index.md b/docs/index.md index 51cc569b..6e8aba4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -271,6 +271,14 @@ Discovery commands let you survey what exists before writing queries—no guessi - One-time processing without local persistence - Memory-efficient iteration over large datasets +**Session Replay** — Discover, fetch, and analyze rrweb session recordings: + +- Discover a user's replays by date window, or hydrate explicit replay IDs +- Fetch the raw rrweb stream (rrweb-player compatible) or stream it with bounded memory +- Project sessions into DataFrames (`sessions_df`, `actions_df`, `elements_df`) plus an LLM-friendly action timeline +- Correlate the Mixpanel events that fired during each session; rank clicks, find rage-clicks, surface error sessions +- Signed CDN URLs are masked by default and never logged + ## For Humans and Agents The structured output and deterministic command interface make `mixpanel_headless` particularly effective for AI coding agents—the same properties that make it scriptable for humans make it reliable for automated workflows. @@ -302,6 +310,7 @@ For interactive exploration of the codebase itself, see [DeepWiki](https://deepw - [Retention Queries](guide/query-retention.md) — Typed retention analysis with event pairs, custom buckets, and alignment modes - [Flow Queries](guide/query-flows.md) — Typed flow path analysis with direction controls and visualization modes - [User Profile Queries](guide/query-users.md) — Typed user profile queries with filtering, sorting, and aggregation +- [Session Replay](guide/session-replay.md) — Discover, fetch, and analyze rrweb session recordings - [API Reference](api/index.md) — Complete Python API documentation - [Entity Management](guide/entity-management.md) — Manage dashboards, reports, cohorts, feature flags, experiments, alerts, annotations, and webhooks - [Data Governance](guide/data-governance.md) — Manage Lexicon definitions, drop filters, custom properties, custom events, and lookup tables diff --git a/mixpanel-plugin/skills/mixpanelyst/SKILL.md b/mixpanel-plugin/skills/mixpanelyst/SKILL.md index 72c8d494..285ff4da 100644 --- a/mixpanel-plugin/skills/mixpanelyst/SKILL.md +++ b/mixpanel-plugin/skills/mixpanelyst/SKILL.md @@ -1,6 +1,6 @@ --- name: mixpanelyst -description: This skill should be used when the user asks about Mixpanel product analytics, event data, funnel analysis, retention curves, cohort analysis, segmentation queries, user behavior, conversion rates, churn, DAU/MAU, ARPU, revenue metrics, feature adoption, A/B test results, user paths, flow analysis, or any request to query, explore, visualize, or analyze Mixpanel data using Python. Also use when the user asks to read, write, or manage Mixpanel "business context" — the markdown documentation that grounds AI assistants in an organization's structure and goals. +description: This skill should be used when the user asks about Mixpanel product analytics, event data, funnel analysis, retention curves, cohort analysis, segmentation queries, user behavior, conversion rates, churn, DAU/MAU, ARPU, revenue metrics, feature adoption, A/B test results, user paths, flow analysis, session replay or session recordings (what a specific user did on screen, click-by-click — rage clicks, dead clicks, error sessions, action timelines), or any request to query, explore, visualize, or analyze Mixpanel data using Python. Also use when the user asks to read, write, or manage Mixpanel "business context" — the markdown documentation that grounds AI assistants in an organization's structure and goals. allowed-tools: Bash Read Write WebFetch --- @@ -994,6 +994,41 @@ print(f"{project_ctx.character_count}/{BUSINESS_CONTEXT_MAX_CHARS} chars; " User Guide: `WebFetch(url="https://mixpanel.github.io/mixpanel-headless/guide/business-context/index.md")` +## Session Replay + +Answers "what did this user actually *do*?" — the click-by-click story behind an analytics number. Fetches rrweb session recordings, runs a vendored analyzer, and projects sessions into DataFrames + an LLM-friendly action timeline. + +**When to reach for this:** the user names a specific `distinct_id` and asks what they did, wants clicks / rage-clicks / error sessions across a cohort of sessions, or wants to correlate a tracked event with on-screen behavior. + +```python +import mixpanel_headless as mp +ws = mp.Workspace() + +# Discover + fetch + Mixpanel-event join in one call → a ReplayBundle. +# Each replay is byte-heavy, so limit defaults to 20 — raise it deliberately. +bundle = ws.replays_for_user("user-42", from_date="2025-01-01", to_date="2025-01-31") + +bundle.sessions_df # one row per session: duration_s, n_clicks, n_errors, entry/exit_url +bundle.replays[0].summary_markdown # action timeline: "Clicked …", "Scrolled (×3)" +bundle.top_clicks(10) # most-clicked elements (focus interactions excluded) +bundle.rage_clicks() # rapid repeated clicks on one target +bundle.error_sessions() # a NEW bundle of only the replays with console errors +``` + +`ReplayBundle` projections: `sessions_df`, `actions_df` (includes a `description` column — the full phrase), `events_df` (raw rrweb), `mixpanel_df` (tracked events in the window), `elements_df` (per-element click counts, URL-normalized). Filters return new bundles: `.where(distinct_id=, contains_url=, has_event=, min_duration_s=)`, `.filter(predicate)`, `.find_pattern([...])`, `.head(n)`, `.sample(n, seed)`, `.compare(other)`. + +Single replay + raw stream for the rrweb JS player: + +```python +replay = ws.fetch_replay("0190ebde-d50d-71b1-804c-ec1b4a533ef9") +replay.to_rrweb_player_json() # timestamp-sorted rrweb events +``` + +**Signed CDN URLs are bearer credentials** — `SignedReplay` masks them and the library never logs them. A `SESSION_RECORDING_SENSITIVE_DATA` 403 raises `SessionReplayAccessError`. + +Look up the surface: `help.py Workspace.replays_for_user`, `help.py ReplayBundle`, `help.py Replay`. +User Guide: `WebFetch(url="https://mixpanel.github.io/mixpanel-headless/guide/session-replay/index.md")` + ## Key Types Run `python3 ${CLAUDE_SKILL_DIR}/scripts/help.py types` for the full list of all types. Use `help.py ` for fields, constructors, and enum values. @@ -1019,6 +1054,8 @@ Full reference: `WebFetch(url="https://mixpanel.github.io/mixpanel-headless/api/ | `CohortCriteria` | Atomic condition for cohort membership | | `CustomPropertyRef` | Reference to a persisted custom property by ID | | `InlineCustomProperty` | Ephemeral computed property defined by formula | +| `ReplayBundle` / `Replay` | Session replay — DataFrame projections + `summary_markdown` | +| `ReplaySummary` | Lightweight replay discovery handle from `list_replays` | **Aggregation enums** (use `help.py ` to see all values): diff --git a/mkdocs.yml b/mkdocs.yml index 2fd64b87..cdccf14c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,6 +88,7 @@ plugins: - guide/query-users.md - guide/live-analytics.md - guide/streaming.md + - guide/session-replay.md - guide/entity-management.md - guide/data-governance.md - guide/business-context.md @@ -143,6 +144,7 @@ plugins: - guide/query-users.md: "Typed user profile queries — filtering, sorting, parallel fetching, aggregate counts" - guide/live-analytics.md: "Segmentation, funnels, retention" - guide/streaming.md: "Stream events and profiles for ETL" + - guide/session-replay.md: "Discover, fetch, and analyze rrweb session recordings" - guide/entity-management.md: "Create, update, delete dashboards, reports, and cohorts" - guide/data-governance.md: "Manage Lexicon definitions, drop filters, custom properties, custom events, lookup tables, schema registry, enforcement, auditing, anomalies, and event deletion requests" - guide/business-context.md: "Read and write the markdown business context that grounds AI assistants (org and project scopes, 50,000-char cap)" @@ -189,6 +191,7 @@ nav: - User Profile Queries: guide/query-users.md - Live Analytics: guide/live-analytics.md - Streaming Data: guide/streaming.md + - Session Replay: guide/session-replay.md - Entity Management: guide/entity-management.md - Data Governance: guide/data-governance.md - Business Context: guide/business-context.md diff --git a/specs/044-session-replay/checklists/requirements.md b/specs/044-session-replay/checklists/requirements.md new file mode 100644 index 00000000..adca8bd8 --- /dev/null +++ b/specs/044-session-replay/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Session Replay for `mixpanel-headless` + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +This is a Python library feature, so the user-facing surface includes the public Python API and CLI commands. Function names and parameter signatures appear in requirements because they ARE the user-facing surface for this audience (developers writing Python or shell scripts). What would constitute "implementation details" — internal file layout, module-private types, HTTP client mechanics, mutation-test infrastructure — is kept in the companion plan and out of the spec. + +Tradeoff acknowledged: a strict reading of "no APIs in spec" would flag every `Workspace.list_replays(...)` reference. For library specs aimed at developer-users, treating the public API contract as user-facing is the convention used by prior specs in this repo (e.g. `043-frictionless-auth`, `040-query-engine-completeness`). + +Source plan (`context/session-replay-plan.md`) is the authoritative implementation companion — file layout, dependency injection patterns, mocking strategy, and phase sequencing live there. diff --git a/specs/044-session-replay/contracts/cli-commands.md b/specs/044-session-replay/contracts/cli-commands.md new file mode 100644 index 00000000..da8d9126 --- /dev/null +++ b/specs/044-session-replay/contracts/cli-commands.md @@ -0,0 +1,249 @@ +# Contract: CLI Commands + +**Feature**: 044-session-replay +**Surface**: `mp replays` command group +**Audience**: Operators, support engineers, agents invoking the CLI + +A new Typer command group `mp replays` registered in `cli/main.py::_register_commands()`. All commands follow the existing pattern: `@handle_errors`, `get_workspace(ctx)`, `output_result(ctx, ..., format=format)`. + +--- + +## 1. Command surface + +| Command | Phase | Purpose | +|---------|-------|---------| +| `mp replays list` | 1 | Discover a user's replays | +| `mp replays events` | 1 | Mixpanel events in a replay's window | +| `mp replays sign` | 1 | Get signed CDN URL(s) | +| `mp replays fetch` | 1 | Pull raw rrweb bytes | +| `mp replays analyze` | 2 | Print markdown timeline | +| `mp replays for-user` | 2 | Discovery + fetch + analyze in one command | + +--- + +## 2. `mp replays list` + +```bash +mp replays list --user USER --from DATE --to DATE [--limit N] [--format FMT] +mp replays list --replay-id ID [--replay-id ID ...] [--format FMT] +``` + +**Options**: +- `--user TEXT`: distinct_id. Mutually exclusive with `--replay-id`. +- `--replay-id TEXT` (repeatable): explicit IDs to hydrate. Mutually exclusive with `--user`. +- `--from TEXT`: ISO date (YYYY-MM-DD). Required with `--user`. +- `--to TEXT`: ISO date (YYYY-MM-DD). Required with `--user`. +- `--limit INT`: default 100. +- `--format [table|json|jsonl|csv|plain]`: default `json`. + +**Default columns** (table format): `replay_id`, `distinct_id`, `started`, `retention_days`. + +**Examples**: +```bash +mp replays list --user user-42 --from 2026-05-20 --to 2026-05-27 +mp replays list --user user-42 --from 2026-05-20 --to 2026-05-27 --format jsonl +mp replays list --replay-id r-19221... --replay-id r-19222... --format json +``` + +**Exit codes**: 0 success (incl. empty result); 2 auth; 3 invalid args. + +--- + +## 3. `mp replays events` + +```bash +mp replays events REPLAY_ID [--properties PROP[,PROP...]] [--format FMT] +``` + +**Options**: +- `REPLAY_ID`: positional, required. +- `--properties TEXT`: comma-separated event properties to include as group keys. Max 5. +- `--format [json|jsonl|csv|plain]`: default `json`. + +**Examples**: +```bash +mp replays events r-19221... --properties '$browser,$current_url' +mp replays events r-19221... --format jsonl +``` + +**Exit codes**: 0 success; 2 auth; 3 invalid args (e.g. >5 properties); 4 replay not found. + +--- + +## 4. `mp replays sign` + +```bash +mp replays sign REPLAY_ID [REPLAY_ID ...] [--env ENV] [--reveal-signed-urls] [--format FMT] +``` + +**Options**: +- `REPLAY_ID` (variadic): one or more IDs. +- `--env [prod|dev]`: default `prod`. +- `--reveal-signed-urls`: opt into full bearer-credential disclosure. **Emits a stderr warning every time used.** +- `--format [json|jsonl|table|plain]`: default `json`. + +**Output (default, redacted)**: +```json +[ + { + "replay_id": "r-19221...", + "url": "https://cdn.mxpnl.com/srr-us/-/", + "query_string": "", + "env": "prod", + "signed_at": 1730000000.0, + "expires_at": 1730000300.0 + } +] +``` + +**Output with `--reveal-signed-urls`** (full): +```json +[ + { + "_warning": "query_string is a bearer credential valid for ~5 minutes", + "replay_id": "r-19221...", + "url": "https://cdn.mxpnl.com/srr-us/-/", + "query_string": "URLPrefix=...&Expires=...&KeyName=...&Signature=...", + "env": "prod", + "signed_at": 1730000000.0, + "expires_at": 1730000300.0 + } +] +``` + +**Stderr on `--reveal-signed-urls`** (every invocation): +``` +warning: signed URLs are bearer credentials valid for ~5 minutes. Treat them +like session tokens — do not paste into chat, logs, or version control. +``` + +**Exit codes**: 0 success; 2 auth (incl. sensitive-data 403); 3 invalid args; 4 replay not found. + +--- + +## 5. `mp replays fetch` + +```bash +mp replays fetch REPLAY_ID [-o FILE] [--env ENV] [--include-events] [--max-files N] +``` + +**Options**: +- `REPLAY_ID`: positional, required. +- `-o`, `--output PATH`: write to file. When omitted, prints a one-line summary to stdout. +- `--env [prod|dev]`: default `prod`. +- `--include-events`: trigger the Mixpanel-events join. +- `--max-files INT`: default 500. + +**File output** (`-o file.json`): JSON array of rrweb events, timestamp-sorted. Directly compatible with the rrweb JS player: + +```javascript +import rrwebPlayer from 'rrweb-player'; +const events = await (await fetch('file.json')).json(); +new rrwebPlayer({ target: document.body, props: { events } }); +``` + +**Stdout output** (no `-o`): +``` +fetched r-19221... — 4823 events, 8m 12s, 30-day retention +``` + +**Exit codes**: 0 success; 2 auth; 4 replay not found. + +--- + +## 6. `mp replays analyze` + +```bash +mp replays analyze REPLAY_ID [--format FMT] +``` + +**Options**: +- `REPLAY_ID`: positional, required. +- `--format [plain|json|markdown]`: default `plain` (= markdown timeline). `json` emits the structured action list. + +**Default output** (markdown timeline to stdout): +```markdown +# Session r-19221... — 8m 12s — alice@acme.com + +## Timeline + +- 14:32:01 navigate to `https://acme.com/login` +- 14:32:04 click `button "Sign in"` +- 14:32:06 input `input[type=email]` (12 chars) +- 14:32:09 input `input[type=password]` (16 chars) +- 14:32:11 click `button "Submit"` +- 14:32:14 navigate to `https://acme.com/dashboard` +... +``` + +**JSON output** (`--format json`): array of normalized `UserAction` records. + +**Exit codes**: 0 success; 2 auth; 4 replay not found. + +**Phase note**: requires Phase 2 (vendored analyzer). In Phase 1, this command does not exist. + +--- + +## 7. `mp replays for-user` + +```bash +mp replays for-user USER --from DATE --to DATE \ + [--include analyze] [--mixpanel-events | --no-mixpanel-events] \ + [--out-dir DIR] [--limit N] +``` + +**Options**: +- `USER`: positional distinct_id, required. +- `--from TEXT`, `--to TEXT`: ISO date window, required. +- `--include analyze` (repeatable): emit per-replay markdown summaries. +- `--mixpanel-events / --no-mixpanel-events`: include the Mixpanel event stream alongside actions. Default on (matches `Workspace.replays_for_user`). +- `--out-dir PATH`: directory to write per-replay output. When omitted, writes to stdout (markdown summaries concatenated). +- `--limit INT`: default 100. + +**Output with `--out-dir DIR --include analyze`**: +``` +DIR/ +├── r-19221...-summary.md +├── r-19222...-summary.md +├── r-19223...-summary.md +└── index.json # bundle.sessions_df.to_json(orient="records") +``` + +**Stdout** (after writing the files): +``` +wrote 3 replays to ./replays/ +total: 24m activity, 12 navigations, 47 clicks, 3 errors +``` + +**Exit codes**: 0 success (incl. empty result); 2 auth; 3 invalid args. + +**Phase note**: requires Phase 2. + +--- + +## 8. Global behaviors + +### Authentication + +All commands use the standard `get_workspace(ctx)` resolution: env → param → target → bridge → config. Auth failures exit with code 2 and a structured error. + +### Error mapping + +| Exception | Exit code | CLI message format | +|-----------|-----------|--------------------| +| `SessionReplayAccessError` | 2 | `error: sensitive replay data — project N has SESSION_RECORDING_SENSITIVE_DATA enabled and your account lacks access` | +| `SignedURLExpiredError` | 1 | `error: signed URL expired (5-minute TTL) — re-run the command` | +| `ReplayNotFoundError` | 4 | `error: replay R not found — may have aged out of retention, never been recorded, or been deleted` | +| `QueryError` (Insights) | 1 | passed through with HTTP status and Mixpanel error message | +| Other `APIError` | 1 | passed through | + +### Output formatting + +All commands honor `--format` per the existing `FormatOption` enum: `json`, `jsonl`, `table`, `csv`, `plain`. Where `--format` is omitted, each command picks the most useful default (see per-command sections). + +### Logging + +- Default: silent on success, structured errors on stderr. +- `-v` (verbose, existing global): progress lines on stderr (e.g. "fetching CDN file 0042-30.json"). +- `-vv` (debug, existing global): URL prefixes (NEVER query strings) on stderr. +- Bearer credentials NEVER logged at any level. diff --git a/specs/044-session-replay/contracts/error-messages.md b/specs/044-session-replay/contracts/error-messages.md new file mode 100644 index 00000000..b03821d3 --- /dev/null +++ b/specs/044-session-replay/contracts/error-messages.md @@ -0,0 +1,231 @@ +# Contract: Error Messages + +**Feature**: 044-session-replay +**Surface**: Stable error message catalog +**Audience**: Callers writing error-handling code; agents pattern-matching CLI stderr + +Error messages are part of the API contract. Changes to wording in this catalog require a minor version bump and a CHANGELOG entry. Stable identifiers (exception class names, `details` dict keys, exit codes) are stricter — they require a major version bump to change. + +--- + +## 1. `SessionReplayAccessError` + +**When raised**: `POST /app/projects//replays/sign/bulk` returns 403 with a body indicating the `SESSION_RECORDING_SENSITIVE_DATA` flag is set on the project AND the calling account lacks sensitive-data access. + +**Python**: +```python +raise SessionReplayAccessError( + message=( + f"Project {project_id} has SESSION_RECORDING_SENSITIVE_DATA enabled. " + f"Your account lacks sensitive-data access. Contact the project owner " + f"to grant the 'sensitive_data_replay' permission, or use a service " + f"account that has it." + ), + details={ + "project_id": project_id, + "flag": "SESSION_RECORDING_SENSITIVE_DATA", + "permission_required": "sensitive_data_replay", + }, + status_code=403, +) +``` + +**CLI**: +``` +error: sensitive replay data — project 3713224 has SESSION_RECORDING_SENSITIVE_DATA +enabled and your account lacks access. Contact the project owner to grant the +'sensitive_data_replay' permission, or use a service account that has it. +``` + +**Exit code**: 2 (auth) + +--- + +## 2. `SignedURLExpiredError` + +**When raised**: A CDN fetch returns 403 with a body indicating signature expiration AND the caller opted out of automatic re-signing (`stream_replay(re_sign_on_expiry=False)`). + +**Python**: +```python +raise SignedURLExpiredError( + message=( + f"Signed URL for replay {replay_id} expired (5-minute TTL). " + f"Re-sign with sign_replay({replay_id!r}) or use the default " + f"re_sign_on_expiry=True on stream_replay." + ), + details={ + "replay_id": replay_id, + "signed_at": signed_at, + "expired_at": expired_at, + }, + status_code=403, +) +``` + +**CLI**: +``` +error: signed URL expired (5-minute TTL) — re-run the command +``` + +**Exit code**: 1 (generic error) + +--- + +## 3. `ReplayNotFoundError` + +**When raised**: The CDN walker requested `0000-N.json` and got a 404. The replay either aged out of retention, was never recorded, or has been deleted. + +**Python**: +```python +raise ReplayNotFoundError( + message=( + f"Replay {replay_id} not found on CDN. The replay may have aged out " + f"of its retention window ({retention_days} days), never been recorded, " + f"or been deleted." + ), + details={ + "replay_id": replay_id, + "retention_days": retention_days, + "cdn_url_prefix": url_prefix, + }, + status_code=404, +) +``` + +**CLI**: +``` +error: replay r-19221... not found — may have aged out of retention (30 days), +never been recorded, or been deleted. +``` + +**Exit code**: 4 (not found) + +--- + +## 4. `ValueError` on bad `events_for_replay` group-by count + +**When raised**: Caller passes more than 5 `event_properties` to `events_for_replay` or `events_for_replays`. + +**Python**: +```python +raise ValueError( + f"events_for_replay accepts at most 5 event_properties (Insights group-by " + f"limit). Got {len(event_properties)}: {event_properties}" +) +``` + +**CLI** (mapped by `handle_errors` decorator): +``` +error: too many event properties — got 7, max is 5 (Insights API limit). +Drop some properties or split into multiple queries. +``` + +**Exit code**: 3 (invalid args) + +--- + +## 5. `ValueError` on `list_replays` argument validation + +**When raised**: Neither or both of `distinct_id` and `replay_ids` provided; or `distinct_id` provided without `from_date`/`to_date`. + +**Python**: +```python +# Neither +raise ValueError( + "list_replays requires exactly one of distinct_id or replay_ids." +) + +# Both +raise ValueError( + "list_replays requires exactly one of distinct_id or replay_ids; both were given." +) + +# distinct_id without window +raise ValueError( + "list_replays(distinct_id=...) requires from_date and to_date." +) +``` + +**Exit code**: 3 (invalid args) + +--- + +## 6. Optional-extra `ImportError` — not applicable + +The replay feature has **no optional-extra-gated surface**, so it raises no +install-time `ImportError`. All projections and aggregations rely only on the +base `dependencies` (pandas); there are no `[replay-mining]` / `[replay-ml]` / +`[replay-all]` extras. + +(Section retained, not renumbered, so §7–§11 — and the code references to §9 / +§10 — keep their numbers.) + +--- + +## 7. Mixpanel API errors (passed through) + +Errors from the underlying Insights API or `/replays/sign[/bulk]` that do NOT match the sensitive-data 403 pattern are raised as the existing `QueryError` / `ServerError` / `APIError`. The library does not invent new error classes for these. + +**Pattern**: existing `MixpanelAPIClient._handle_response()` maps HTTP status to exception, attaches the Mixpanel `error` field and request ID to `details`. + +--- + +## 8. Bearer-credential warning (CLI, `--reveal-signed-urls`) + +**When emitted**: Every invocation of `mp replays sign --reveal-signed-urls`, regardless of TTY / format / stdout destination. + +**Destination**: stderr. + +**Wording**: +``` +warning: signed URLs are bearer credentials valid for ~5 minutes. Treat them +like session tokens — do not paste into chat, logs, or version control. +``` + +**Exit code impact**: none (the warning does not affect exit code). + +--- + +## 9. Mobile-replay attempted (forward-compat marker) + +**When raised**: `fetch_replay` is called against a replay_id whose CDN bytes are not in rrweb format (mobile, future formats). + +**Python**: +```python +raise UnsupportedReplayFormatError( + f"Replay {replay_id} appears to be a mobile session (non-rrweb format). " + f"Mobile session replays are not yet supported by mixpanel-headless. " + f"Track upstream at SR-230.", + details={"replay_id": replay_id, "format": "non-rrweb"}, +) +``` + +`UnsupportedReplayFormatError` is a `SessionReplayError` (default `status_code` 501, Not Implemented). The batch paths (`fetch_replays` / `replays_for_user`) isolate it per-replay: the mobile session is logged and skipped, and the bundle returns the analyzable web sessions. + +**Exit code**: 1 (generic error) + +--- + +## 10. Retention warning (structured log, not exception) + +**When emitted**: `list_replays` encounters a `$mp_session_record` event with no `$mp_replay_retention_period` property. + +**Destination**: structured warning via `warnings.warn()`; under default config emits to stderr. + +**Wording**: +``` +UserWarning: replay r-19221... is missing $mp_replay_retention_period; defaulting +to 30 days. Upgrade your Mixpanel SDK to stamp this property on new recordings. +``` + +**Category**: `UserWarning` (so it can be filtered with `warnings.filterwarnings("ignore", category=UserWarning)`). + +--- + +## 11. Forward compatibility rules + +- **Adding a new exception subclass** is backward-compatible. Existing `except SessionReplayError:` handlers continue to catch it. +- **Adding a new key to a `details` dict** is backward-compatible. Existing callers reading specific keys continue to work. +- **Changing a `message` string** is a minor-version change requiring a CHANGELOG entry. Callers MUST NOT pattern-match on message strings; they should use exception class + `details` keys. +- **Changing an exit code** is a major-version change. +- **Changing a `details` key name** is a major-version change. diff --git a/specs/044-session-replay/contracts/python-api.md b/specs/044-session-replay/contracts/python-api.md new file mode 100644 index 00000000..952dc7e0 --- /dev/null +++ b/specs/044-session-replay/contracts/python-api.md @@ -0,0 +1,376 @@ +# Contract: Python API + +**Feature**: 044-session-replay +**Surface**: `mixpanel_headless` public exports +**Audience**: Developers using `mixpanel-headless` as a Python library + +This contract enumerates every new public symbol and its signature. The companion `data-model.md` documents the shape of returned types; `error-messages.md` documents stable error messages. + +--- + +## 1. `Workspace` methods + +All methods added to `mixpanel_headless.workspace.Workspace`. Group by phase. + +### Phase 1: Discovery + +#### `list_replays` + +```python +def list_replays( + self, + *, + distinct_id: str | None = None, + replay_ids: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + limit: int = 100, +) -> list[ReplaySummary]: + """List replays for a user, or hydrate summaries for explicit IDs. + + Exactly one of `distinct_id` or `replay_ids` MUST be provided. + When `distinct_id` is given, `from_date` and `to_date` are required. + + Args: + distinct_id: Mixpanel user identifier. Mutually exclusive with replay_ids. + replay_ids: Explicit list of replay IDs to hydrate. Mutually exclusive + with distinct_id; from_date/to_date are inferred. + from_date: ISO date string (YYYY-MM-DD). Required when distinct_id is set. + to_date: ISO date string (YYYY-MM-DD). Required when distinct_id is set. + limit: Maximum summaries to return. Default 100. + + Returns: + List of ReplaySummary, possibly empty. + + Raises: + ValueError: If neither or both of distinct_id and replay_ids are provided, + or if distinct_id is set without from_date/to_date. + QueryError: For Insights API failures. + """ +``` + +#### `events_for_replay` + +```python +def events_for_replay( + self, + replay_id: str, + *, + event_properties: list[str] | None = None, +) -> list[ReplayEvent]: + """Mixpanel events that occurred during a replay's time window. + + Filters on $mp_replay_id, excludes $mp_session_record itself. + + Args: + replay_id: The replay ID to fetch events for. + event_properties: Up to 5 additional event properties to include as + group keys. Default None (no extras). + + Returns: + List of ReplayEvent in time order. + + Raises: + ValueError: If len(event_properties) > 5. + QueryError: For Insights API failures. + """ +``` + +#### `events_for_replays` + +```python +def events_for_replays( + self, + replay_ids: list[str], + *, + event_properties: list[str] | None = None, +) -> dict[str, list[ReplayEvent]]: + """Batched version of events_for_replay. Single round-trip. + + Args: + replay_ids: List of replay IDs. + event_properties: Up to 5 additional event properties. + + Returns: + Dict mapping replay_id to its event list. Missing keys for replays + with no events. + """ +``` + +### Phase 1: Signed CDN access + +#### `sign_replay` + +```python +def sign_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", +) -> SignedReplay: + """Single-replay signing sugar over sign_replays.""" +``` + +#### `sign_replays` + +```python +def sign_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", +) -> list[SignedReplay]: + """Sign multiple replays via POST /app/projects//replays/sign/bulk. + + Args: + replay_ids: List of replay IDs. No documented maximum. + env: "prod" or "dev". Default "prod". + + Returns: + List of SignedReplay, one per requested ID, in the same order. + + Raises: + SessionReplayAccessError: 403 with SESSION_RECORDING_SENSITIVE_DATA flag set. + APIError: Other 4xx/5xx responses. + """ +``` + +### Phase 1: Fetch + +#### `fetch_replay` + +```python +def fetch_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + cdn_concurrency: int = 50, +) -> Replay: + """Sign + fetch + parse + return a populated Replay. + + When retention_days is None, list_replays is consulted to discover + the actual retention period. Pass explicitly to skip the lookup. + + Args: + replay_id: The replay to fetch. + env: "prod" or "dev". + retention_days: 1, 7, 30, or 90. Auto-discovered if None. + max_files: Hard upper bound on CDN file walk. Default 500. + include_mixpanel_events: Trigger a follow-up Insights query to populate + Replay.mixpanel_events. Default False. + event_properties: Up to 5 properties for the Mixpanel join query. + cdn_concurrency: Parallel batch size for CDN fetches. Default 50. + + Returns: + Replay with rrweb_events populated. In Phase 1, actions is always empty. + + Raises: + ReplayNotFoundError: First CDN file (0000-N.json) returned 404. + SessionReplayAccessError: Sensitive-data flag set. + SignedURLExpiredError: Signed URL expired mid-fetch (rare; fetch_replay + signs and fetches in immediate succession). + """ +``` + +#### `stream_replay` + +```python +def stream_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + re_sign_on_expiry: bool = True, + cdn_concurrency: int = 50, +) -> Iterator[dict[str, Any]]: + """Yield rrweb events one at a time, batched-parallel under the hood. + + Fetches files in batches of `cdn_concurrency`. Within a batch, yields + events in timestamp order. Does not buffer across batches. + + Args: + replay_id: The replay to stream. + env: "prod" or "dev". + retention_days: 1, 7, 30, or 90. Auto-discovered if None. + max_files: Hard upper bound on CDN file walk. Default 500. + re_sign_on_expiry: When True (default), catches 403 indicating + signature expiration and re-signs transparently. When False, + propagates SignedURLExpiredError. + cdn_concurrency: Parallel batch size. Default 50. + + Yields: + Raw rrweb event dicts in timestamp order. + + Raises: + ReplayNotFoundError: First CDN file returned 404. + SignedURLExpiredError: Signed URL expired and re_sign_on_expiry=False. + SessionReplayAccessError: Sensitive-data flag set. + """ +``` + +### Phase 2: Bundle + +#### `fetch_replays` + +```python +def fetch_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + concurrency: int = 4, + cdn_concurrency: int = 50, +) -> ReplayBundle: + """Sign + fetch + parse N replays in parallel; return a ReplayBundle. + + Args: + replay_ids: Replays to fetch. + concurrency: How many replays to fetch in parallel. Default 4. + cdn_concurrency: Per-replay CDN batch size. Default 50. + (other args same as fetch_replay) + + Returns: + ReplayBundle with all requested replays populated. + """ +``` + +#### `replays_for_user` + +```python +def replays_for_user( + self, + distinct_id: str, + *, + from_date: str, + to_date: str, + limit: int = 100, + include_mixpanel_events: bool = True, + event_properties: list[str] | None = None, +) -> ReplayBundle: + """Discovery + fetch in one call. The "show me this user's recent + activity" convenience method. + + Args: + distinct_id: Mixpanel user identifier. + from_date, to_date: ISO date window. + limit: Maximum replays. Default 100. + include_mixpanel_events: Default True for this convenience method. + event_properties: Up to 5 properties for Mixpanel join. + + Returns: + ReplayBundle, possibly empty if no replays exist in the window. + """ +``` + +#### `analyze_replay` + +```python +def analyze_replay(self, replay_id: str) -> str: + """Sign + fetch + run the analyzer + return the markdown timeline. + + Sugar for: `ws.fetch_replay(replay_id).summary_markdown`. + + Returns: + Markdown string suitable for stdout or LLM consumption. + """ +``` + +--- + +## 2. Result types + +Documented in detail in [data-model.md](../data-model.md). Quick reference: + +| Type | Returned by | Key feature | +|------|-------------|-------------| +| `ReplaySummary` | `list_replays` | Discovery handle, no bytes | +| `SignedReplay` | `sign_replay(s)` | Time-bounded CDN access, bearer credential | +| `UserAction` | `Replay.actions[i]` | Normalized action from analyzer | +| `ReplayEvent` | `events_for_replay(s)`, `Replay.mixpanel_events[i]` | Mixpanel event in window | +| `Replay` | `fetch_replay` | Single materialized session | +| `ReplayBundle` | `fetch_replays`, `replays_for_user` | Collection with cross-session DataFrame projections | + +--- + +## 3. Exception hierarchy + +```python +APIError # existing +└── SessionReplayError # NEW base + ├── SessionReplayAccessError # 403 with SESSION_RECORDING_SENSITIVE_DATA + ├── SignedURLExpiredError # 5-minute TTL expired + └── ReplayNotFoundError # CDN walk found nothing +``` + +See [error-messages.md](error-messages.md) for stable error messages. + +--- + +## 4. Label functions + +Defined in the public `mixpanel_headless.replay_labels` module. Re-exported from the top-level `mixpanel_headless` package. + +```python +def default_label_fn(action: UserAction) -> str: + """Default activity label: f"{action}:{tag_name}@{normalized_url}". + + URL normalization: strip query strings; replace numeric path segments + with ':id' (e.g. /users/12345/profile → /users/:id/profile). + """ + +def selector_label_fn(attr: str = "data-testid") -> Callable[[UserAction], str]: + """Returns a label_fn that prefers a stable selector attribute when + present, falling back to default_label_fn otherwise. + + Example: + bundle.find_pattern(["click:button@/"], label_fn=selector_label_fn("data-testid")) + """ + +def url_normalizer(url: str) -> str: + """Strip query strings; replace numeric path segments with ':id'.""" +``` + +--- + +## 5. Public exports (`__init__.py`) + +```python +# Added to mixpanel_headless.__all__: +"Replay", +"ReplayBundle", +"ReplaySummary", +"SignedReplay", +"ReplayEvent", +"UserAction", +"SessionReplayError", +"SessionReplayAccessError", +"SignedURLExpiredError", +"ReplayNotFoundError", +"default_label_fn", +"selector_label_fn", +``` + +--- + +## 6. Phase boundaries (Python API) + +| Method | Phase | Notes | +|--------|-------|-------| +| `list_replays` | 1 | Required for everything else | +| `events_for_replay(s)` | 1 | Required for `include_mixpanel_events` | +| `sign_replay(s)` | 1 | | +| `fetch_replay` | 1 | `Replay.actions` empty in Phase 1 | +| `stream_replay` | 1 | | +| `fetch_replays` | 2 | Requires `ReplayBundle` | +| `replays_for_user` | 2 | Sugar over `list_replays` + `fetch_replays` | +| `analyze_replay` | 2 | Requires vendored analyzer | diff --git a/specs/044-session-replay/data-model.md b/specs/044-session-replay/data-model.md new file mode 100644 index 00000000..9b782238 --- /dev/null +++ b/specs/044-session-replay/data-model.md @@ -0,0 +1,429 @@ +# Phase 1 Data Model: Session Replay + +**Feature**: 044-session-replay +**Date**: 2026-05-27 + +The session-replay feature adds six new in-memory types and one new exception hierarchy. No on-disk persistence: signed URLs are time-bounded bearer credentials handled in-process; `Replay` and `ReplayBundle` are computed-on-demand result types. This document is the entity ledger plus the state-transition table for the discovery → sign → fetch → analyze pipeline. + +--- + +## 1. Reused entities (no changes) + +| Entity | Source | Notes | +|--------|--------|-------| +| `ResultWithDataFrame` | `types.py` | Mixin used by every existing result class (e.g. `FlowQueryResult`). `Replay` and `ReplayBundle` inherit it; provides `.df`, `.to_dict()`, `.__repr_html__()` for notebook display. | +| `Workspace` | `workspace.py` | Public facade. Gains 9 new methods (Phase 1: 4; Phase 2: 5). No schema change. | +| `MixpanelAPIClient` | `_internal/api_client.py` | Gains `sign_replays(replay_ids, env)` method and one new error-mapping case (403 → `SessionReplayAccessError`). No schema change. | +| `Filter` / `InsightsBookmarkParams` | `_internal/query/` | Reused by `list_replays` and `events_for_replay` discovery queries. No schema change. | +| `QueryResult` | `types.py` | Returned by the underlying `Workspace.query()` call; `ReplaySummary` and `ReplayEvent` are constructed by walking its raw `.series` nested dict (skipping `$overall` rollups), **not** its single-level `.df` projection, which cannot represent the multi-key replay group-by. | +| `APIError`, `QueryError`, `ServerError` | `exceptions.py` | Parent classes for the new `SessionReplayError` hierarchy. | + +--- + +## 2. New result types + +All defined in `mixpanel_headless.types`. + +### 2.1 `ReplaySummary` + +Lightweight discovery handle. Does not include recording bytes. Returned by `Workspace.list_replays()`. + +```python +@dataclass(frozen=True) +class ReplaySummary(ResultWithDataFrame): + """Discovery handle for a single replay. + + Returned by Workspace.list_replays(). Use Workspace.fetch_replay(s.replay_id) + to materialize the full Replay with rrweb event bytes. + """ + replay_id: str + distinct_id: str | None + project_id: int + start_time: int # unix ms; from $mp_session_record event timestamp + retention_days: int # from $mp_replay_retention_period; defaults to 30 +``` + +**Validation**: +- `replay_id` MUST be non-empty. +- `project_id` MUST be positive. +- `start_time` MUST be a valid unix ms timestamp (positive int). +- `retention_days` MUST be in `{1, 7, 30, 90}` (the allowed Mixpanel retention values). + +--- + +### 2.2 `SignedReplay` + +Time-bounded CDN access handle. Bearer-credential semantics enforced via `__repr__` masking, `expires_at` arithmetic, and `is_expired` boolean. + +```python +@dataclass(frozen=True) +class SignedReplay: + """Signed CDN access for one replay. + + SECURITY: query_string is a bearer credential valid for ~5 minutes. + Treat it like a session token. __repr__ masks it. + """ + replay_id: str + url: str # CDN prefix, trailing slash; e.g. "https://cdn.mxpnl.com/srr-us/-/" + query_string: str # signed credential; MASKED IN __repr__ + env: Literal["prod", "dev"] + signed_at: float # unix seconds (for expiration arithmetic) + + def __repr__(self) -> str: + masked = f"" + return ( + f"SignedReplay(replay_id={self.replay_id!r}, url={self.url!r}, " + f"query_string={masked!r}, env={self.env!r}, signed_at={self.signed_at!r})" + ) + + __str__ = __repr__ + + @property + def expires_at(self) -> float: + """Approximate expiration timestamp (signed_at + 5 minutes).""" + return self.signed_at + 300 + + @property + def is_expired(self) -> bool: + return time.time() >= self.expires_at + + def to_dict(self) -> dict[str, Any]: + """Full serialization including the bearer credential. + + WARNING: includes the full bearer credential. The returned dict carries + a top-level `_warning` key noting the bearer nature. + """ + return { + "_warning": "query_string is a bearer credential valid for ~5 minutes", + "replay_id": self.replay_id, + "url": self.url, + "query_string": self.query_string, + "env": self.env, + "signed_at": self.signed_at, + } +``` + +**Validation**: +- `query_string` MUST be non-empty (server contract). +- `url` MUST end with `/`. +- `env` MUST be one of `{"prod", "dev"}`. +- `signed_at` MUST be a non-negative float (seconds since epoch). + +--- + +### 2.3 `UserAction` + +Normalized user action extracted from rrweb events by the vendored analyzer. The atomic unit `ReplayBundle` aggregates over. + +```python +@dataclass(frozen=True) +class UserAction: + """Normalized user action from rrweb event stream. + + Produced by the vendored rrweb analyzer (Phase 2). The atomic unit + bundle aggregations operate over. + """ + timestamp: int # unix ms + action: Literal[ + "click", "input", "scroll", "navigate", + "select", "console_error", "viewport_resize", + "touch_start", "media_interaction", + ] + target_node_id: int | None + target_desc: str # e.g. 'button "Sign in"', 'input[type=email]' + url: str | None # active page URL at the time of the action + metadata: dict[str, Any] # action-specific extras (text_length, is_checked, etc.) + description: str = "" # full phrase for the markdown timeline, + # e.g. 'Clicked button "Sign in"', 'Scrolled' +``` + +**Validation**: +- `timestamp` MUST be a valid unix ms timestamp. +- `target_desc` MUST be non-empty (analyzer always produces a description, even if generic). +- `description` is the analyzer's full human-readable phrase; renderers fall back to `target_desc` when it is empty (hand-built fixtures). +- `metadata` keys depend on `action`; documented in the analyzer module docstring. + +--- + +### 2.4 `ReplayEvent` + +A Mixpanel event that occurred during a replay's time window. Optional enrichment on `Replay` / `ReplayBundle`. + +```python +@dataclass(frozen=True) +class ReplayEvent(ResultWithDataFrame): + """Mixpanel event in a replay's time window.""" + replay_id: str + event_name: str + event_time: int # unix seconds (Mixpanel native) + properties: dict[str, Any] | None +``` + +**Validation**: +- `replay_id`, `event_name` MUST be non-empty. +- `event_time` MUST be a valid unix seconds timestamp. + +--- + +### 2.5 `Replay` + +Single fully-materialized session. Conceptually a `ReplayBundle` of size 1; the same DataFrame projections are available on both. + +```python +@dataclass(frozen=True) +class Replay(ResultWithDataFrame): + """Single fully-materialized session replay.""" + replay_id: str + distinct_id: str | None + project_id: int + start_time: int # unix ms + end_time: int # unix ms + retention_days: int + + rrweb_events: list[dict[str, Any]] # raw rrweb events, timestamp-sorted + actions: list[UserAction] # populated by analyzer (Phase 2); + # empty list in Phase 1 + mixpanel_events: list[ReplayEvent] # populated only when fetched + # with include_mixpanel_events=True + + # Cached projections (lazy, computed on first access) + _events_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _actions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _mixpanel_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + + # DataFrame projections + @property + def events_df(self) -> pd.DataFrame: ... + @property + def actions_df(self) -> pd.DataFrame: ... + @property + def mixpanel_df(self) -> pd.DataFrame: ... + @property + def df(self) -> pd.DataFrame: + """Default projection: actions_df.""" + return self.actions_df + + # Convenience accessors + @property + def duration_seconds(self) -> float: ... + @property + def errors(self) -> pd.DataFrame: ... + @property + def summary_markdown(self) -> str: ... + def page_path(self) -> list[str]: ... + def clicks_on(self, predicate: Callable[[UserAction], bool]) -> pd.DataFrame: ... + def to_rrweb_player_json(self) -> list[dict[str, Any]]: ... +``` + +**DataFrame column contracts** (see `quickstart.md` for full schemas): + +- `events_df`: `t`, `type`, `source`, `mouse_type`, `target_node_id`, `url`, `raw` +- `actions_df`: `t`, `action`, `target_node_id`, `target_desc`, `description`, `url`, `metadata` +- `mixpanel_df`: empty unless `include_mixpanel_events=True`; columns match `ReplayBundle.mixpanel_df` + +**Analyzer output**: the analyzer populates `actions`; `summary_markdown` renders each action's full `description`, collapsing consecutive duplicates into a `(×N)` suffix. `page_path()` derives the navigation URL sequence from the `navigate` actions. + +--- + +### 2.6 `ReplayBundle` + +Collection of `Replay` objects with cross-session DataFrame projections. The high-leverage type. + +```python +@dataclass(frozen=True) +class ReplayBundle(ResultWithDataFrame): + """Collection of replays with cross-session projections.""" + replays: list[Replay] + computed_at: str # ISO 8601 timestamp + project_id: int + + # Cached DataFrame projections + _sessions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _actions_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _events_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _mixpanel_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + _elements_df_cache: pd.DataFrame | None = field(default=None, repr=False, kw_only=True) + + # DataFrame projections + @property + def sessions_df(self) -> pd.DataFrame: ... + @property + def actions_df(self) -> pd.DataFrame: ... + @property + def events_df(self) -> pd.DataFrame: ... + @property + def mixpanel_df(self) -> pd.DataFrame: ... + @property + def elements_df(self) -> pd.DataFrame: ... + @property + def df(self) -> pd.DataFrame: + """Default projection: sessions_df.""" + return self.sessions_df + + # Aggregations (return DataFrames; top_clicks/elements_df exclude focus) + def top_clicks(self, n: int = 10) -> pd.DataFrame: ... + def rage_clicks(self, threshold: int = 3, window_ms: int = 1000) -> pd.DataFrame: ... + def long_pauses(self, threshold_s: float = 10) -> pd.DataFrame: ... + + # Filters (return new ReplayBundle — immutable semantics) + def filter(self, predicate: Callable[[Replay], bool]) -> "ReplayBundle": ... + def where(self, *, distinct_id=None, contains_url=None, has_event=None, + min_duration_s=None, max_duration_s=None) -> "ReplayBundle": ... + def find_pattern(self, action_sequence: list[str], *, label_fn=None) -> "ReplayBundle": ... + def error_sessions(self) -> "ReplayBundle": ... + def head(self, n: int = 5) -> "ReplayBundle": ... + def sample(self, n: int = 5, seed: int | None = None) -> "ReplayBundle": ... + + # Enrichment + def join_mixpanel_events(self, properties: list[str] | None = None) -> "ReplayBundle": ... + + # Summary / comparison + @property + def summary_markdown(self) -> str: ... + def compare(self, other: "ReplayBundle") -> pd.DataFrame: ... +``` + +**DataFrame column contracts** (full schemas in `quickstart.md`): + +| Projection | Grain | Key columns | +|------------|-------|-------------| +| `sessions_df` | one row per replay | `replay_id`, `distinct_id`, `start_time`, `end_time`, `duration_s`, `retention_days`, `n_events`, `n_actions`, `n_clicks`, `n_inputs`, `n_pages`, `n_errors`, `n_mp_events`, `entry_url`, `exit_url` | +| `actions_df` | long format, all replays | `replay_id`, `t`, `action`, `target_node_id`, `target_desc`, `description`, `url`, `metadata` | +| `events_df` | long format, raw rrweb | `replay_id`, `t`, `type`, `source`, `mouse_type`, `target_node_id`, `url`, `raw` | +| `mixpanel_df` | long format, Mixpanel events | `replay_id`, `t`, `event_name`, `properties` | +| `elements_df` | per element across all replays | `target_desc`, `url` (normalized), `n_clicks`, `n_unique_replays` | + +--- + +## 3. Exception hierarchy + +All defined in `mixpanel_headless.exceptions`. + +```python +class SessionReplayError(APIError): + """Base for session-replay-specific errors.""" + +class SessionReplayAccessError(SessionReplayError): + """The project has SESSION_RECORDING_SENSITIVE_DATA enabled and the + caller lacks sensitive-data access. + + details = {"project_id": int, "flag": "SESSION_RECORDING_SENSITIVE_DATA"} + """ + +class SignedURLExpiredError(SessionReplayError): + """A signed URL passed to a CDN fetch has expired (5-minute TTL). + Re-sign and retry; or set re_sign_on_expiry=True on stream_replay. + """ + +class ReplayNotFoundError(SessionReplayError): + """A specific replay_id was requested but no CDN files were found. + The replay may have aged out of retention, never been recorded, + or been deleted. + """ +``` + +**Inheritance**: every new class subclasses `APIError` via `SessionReplayError`. Existing `handle_errors` decorator in `cli/utils.py` already catches `APIError` and maps to exit codes — no CLI surface changes needed. + +--- + +## 4. State transitions + +### 4.1 Discovery → Sign → Fetch pipeline + +``` +distinct_id + date range + │ + ▼ +list_replays() ── 1 RTT to /api/query/insights + │ + ▼ +list[ReplaySummary] + │ + ▼ (per replay_id, or batched) +sign_replays() ── 1 RTT to /app/projects//replays/sign/bulk + │ + ├─ 403 sensitive-data flag ─► SessionReplayAccessError + └─ 200 ────────────────────► list[SignedReplay] + │ + ▼ + fetch_replay() / stream_replay() + │ + ├─ Walk CDN files NNNN-RR.json in parallel + ├─ 404 on file 0000 ─► ReplayNotFoundError + ├─ 404 mid-walk ────► clean termination (end sentinel) + ├─ 403 mid-walk ────► re-sign + retry (default ON) + │ OR SignedURLExpiredError (if OFF) + └─ 200 ──────────────► concatenate, sort by t + │ + ▼ + Replay (Phase 1: actions=[]) + │ + ▼ (Phase 2) + analyzer.parse(rrweb_events) + │ + ▼ + Replay (with actions populated) +``` + +### 4.2 `ReplayBundle` construction + +``` +list[Replay] + │ + ▼ +ReplayBundle(replays=..., computed_at=now, project_id=...) + │ + ▼ (lazy on first access) + ├─ sessions_df (one row per replay; derived columns: n_events, duration_s, ...) + ├─ actions_df (long format) + ├─ events_df (long format) + ├─ mixpanel_df (empty unless join_mixpanel_events was called) + └─ elements_df (aggregated by target_desc + normalized url, focus excluded) +``` + +### 4.3 `ReplayBundle` filter chain + +Filters return new bundles. Original is unchanged. Caches are NOT shared across bundles (each new bundle has its own cache slots). + +``` +bundle (10 replays) + │ + ├─ .where(distinct_id="user-42") ── new bundle (subset) + │ │ + │ ├─ .head(5) ── new bundle (≤5 replays) + │ └─ .find_pattern([...]) ── new bundle (subset matching pattern) + │ + └─ .filter(lambda r: r.duration_seconds > 60) ── new bundle + │ + └─ .sample(n=3, seed=42) ── new bundle (3 deterministic replays) +``` + +--- + +## 5. Invariants (verified by PBT) + +These properties are tested via Hypothesis in `tests/pbt/test_types_replay_bundle_pbt.py`: + +- **Sessions cardinality**: `len(bundle.sessions_df) == len(bundle.replays)` for every bundle. +- **Actions sum**: `bundle.actions_df.groupby("replay_id").size().sum() == sum(len(r.actions) for r in bundle.replays)`. +- **Filter subset**: `set(r.replay_id for r in bundle.filter(p).replays) ⊆ set(r.replay_id for r in bundle.replays)`. +- **Filter ↔ where equivalence**: `bundle.where(distinct_id=x).replays == bundle.filter(lambda r: r.distinct_id == x).replays`. +- **Head bound**: `len(bundle.head(n).replays) <= min(n, len(bundle.replays))`. +- **Sample bound**: `len(bundle.sample(n, seed=k).replays) == min(n, len(bundle.replays))`. +- **Sample determinism**: `bundle.sample(n, seed=k).replays == bundle.sample(n, seed=k).replays` (same seed, same output). +- **Immutability**: applying any filter / sample method does NOT change the original `bundle.replays` list. +- **Label stability**: `default_label_fn(a) == default_label_fn(a')` whenever `a` and `a'` differ only in `metadata` keys not used by the label. + +--- + +## 6. Validation rules summary + +| Type | Validation | +|------|------------| +| `ReplaySummary` | `replay_id` non-empty; `project_id > 0`; `start_time > 0`; `retention_days ∈ {1, 7, 30, 90}` | +| `SignedReplay` | `url` ends with `/`; `query_string` non-empty; `env ∈ {"prod", "dev"}`; `signed_at >= 0` | +| `UserAction` | `timestamp > 0`; `target_desc` non-empty; `action` in the documented Literal set | +| `ReplayEvent` | `replay_id`, `event_name` non-empty; `event_time > 0` | +| `Replay` | `start_time <= end_time`; `rrweb_events` timestamp-sorted; `retention_days` matches summary | +| `ReplayBundle` | `replays` not None (can be empty); `computed_at` is ISO 8601; all replays share `project_id` | +| `events_for_replay` | `len(event_properties) <= 5` (Insights group-by cap, see R-10) | diff --git a/specs/044-session-replay/plan.md b/specs/044-session-replay/plan.md new file mode 100644 index 00000000..2e4e3be1 --- /dev/null +++ b/specs/044-session-replay/plan.md @@ -0,0 +1,153 @@ +# Implementation Plan: Session Replay for `mixpanel-headless` + +**Branch**: `044-session-replay` (proposed) | **Date**: 2026-05-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/044-session-replay/spec.md` +**Source design**: [`context/session-replay-plan.md`](../../context/session-replay-plan.md) — the original detailed design draft. This plan distills it into spec-kit shape; the source remains authoritative for fine-grained file layout, vendoring decisions, and PR-shape rationale. +**PR strategy**: Phased. Phase 1 (discovery + signed CDN access + `Replay`) ships as one PR. Phase 2 (vendored rrweb analyzer + `ReplayBundle`) ships as a second PR. Each phase is independently shippable and adds value on its own. + +## Summary + +Add a first-class session replay surface to `mixpanel-headless` covering discovery via the existing Insights query path, signed CDN access to raw rrweb recording files via Mixpanel's `/app/projects//replays/sign[/bulk]` endpoints (used today by Mixpanel's own MCP server), a vendored rrweb analyzer that converts raw event streams into normalized user-action timelines, and two typed result classes (`Replay` for single sessions, `ReplayBundle` for collections) that mirror the existing `FlowQueryResult` idiom with long-format pandas DataFrames keyed by `replay_id`. A new `mp replays` CLI group exposes `list`, `events`, `sign`, `fetch`, `analyze`, and `for-user` commands. (The graph/tree/path-mining projections from the original draft were cut after live QA showed they produce empty or degenerate output on real SPA sessions.) + +The technical approach treats a replay as an event log (timestamped activities keyed by a case ID) so the data shape aligns with every PyData library that touches sequential data (`pandas`, `duckdb`). `ReplayBundle` is the high-leverage type; a `Replay` is conceptually a bundle of size 1, and the API treats them that way. + +Estimated scope: ~2,700 LoC across ~23 new or modified files. Two phases. Total ~3 weeks of focused work. + +## Technical Context + +**Language/Version**: Python 3.10+ (mypy --strict compliant) + +**Primary Dependencies**: +- Reused: `httpx` (HTTP client and CDN fetcher), Pydantic v2 (validation), pandas (DataFrames), Typer (CLI), Rich (output), Hypothesis (PBT), mutmut (mutation testing). +- New (vendored, no third-party install): rrweb analyzer ported from `analytics/backend/replays/rrweb_analyzer.py` (~600 LoC, pure stdlib). + +**Storage**: None. Signed URLs are time-bounded bearer credentials; the library does not persist them. No new disk artifacts beyond what `httpx` already handles in-process. + +**Testing**: pytest (unit + integration); Hypothesis PBT for label-fn stability, file-numbering walker, and DataFrame projection invariants; mutmut on the vendored analyzer + new query builders + labels module. Integration tests gated on a known replay-bearing fixture project (Mixpanel Labs internal project ID `3713224` or equivalent). + +**Target Platform**: Cross-platform (macOS, Linux, Windows). No platform-specific code paths. Filesystem permissions inherit the existing 042 redesign (`0o600` token files, `0o700` parent dirs) — the replay feature adds no new on-disk credential surface. + +**Project Type**: Library + CLI feature addition. No plugin changes; the `mixpanel-plugin/` skills already call into `Workspace` and pick up the new methods automatically. + +**Performance Goals**: +- `list_replays(distinct_id, from_date, to_date)` ≤ 1 round trip for any date range up to 90 days (single Insights call). +- `sign_replays(ids)` ≤ 1 round trip for up to 1000 replay IDs (the MCP server caps at 20 for LLM-context reasons; headless can comfortably batch 100+). +- `fetch_replay(replay_id)` parallel CDN fetch with concurrency 50, terminates on first 404; a 30 MB replay completes in under 5 s on a typical broadband connection. +- `stream_replay(replay_id)` first event yielded within 1 s of call (signed URL + first file fetch). +- `Replay.actions_df` materialization ≤ 200 ms for a 30 MB replay. +- `ReplayBundle.actions_df` materialization ≤ 100 ms per replay in the bundle (linear scaling, cached after first access). + +**Constraints**: +- mypy --strict, zero `Any` lacking explicit justification. +- ruff format / check passes with zero violations. +- 90% test coverage minimum (CI fails below). +- 80% mutation score on `_internal/services/replays.py`, `_internal/replays/rrweb_analyzer.py`, `replay_labels.py`, `_internal/replays/aggregators.py`. +- Signed-URL `query_string` MUST NOT appear in any log line at any level. `__repr__` of `SignedReplay` MUST mask the field. Reviewer audit: grep transcript for any leak. +- Vendored analyzer MUST remain pure-Python (no native deps) so it works in every environment `mixpanel-headless` already supports. + +**Scale/Scope**: +- Phase 1: ~5 new files + 4 modified, ~1,200 LoC including tests. +- Phase 2: ~4 new files (vendored analyzer, labels, aggregators, ReplayBundle expansion), ~1,500 LoC. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Evidence | +|-----------|--------|----------| +| I. Library-First | PASS | Every `mp replays` command delegates to a public `Workspace` method (`list_replays`, `sign_replays`, `fetch_replay`, `stream_replay`, `fetch_replays`, `replays_for_user`, `analyze_replay`, `events_for_replay`, `events_for_replays`). CLI does I/O formatting only. All public methods have type hints and docstrings. | +| II. Agent-Native | PASS | No interactive prompts on any default path. All output is structured (JSON / JSONL / table) and pipe-composable. The vendored analyzer produces deterministic markdown; no LLM-call required. The `--reveal-signed-urls` flag is the only opt-in, never required. | +| III. Context Window Efficiency | PASS | `ReplaySummary` is a lightweight discovery handle (no bytes). `stream_replay` keeps memory bounded for large recordings. `Replay.summary_markdown` is the LLM-context-friendly projection; full DataFrames are opt-in. The bundle-level convenience aggregations (`top_clicks`, `rage_clicks`, ...) return precise answers rather than raw streams. | +| IV. Two Data Paths | PASS | Live query path: `Workspace.query()` for discovery + signed-URL fetch. Local analysis path: `Replay`/`ReplayBundle` DataFrames feed directly into DuckDB via `duckdb.from_df(bundle.actions_df)`. Both paths share the authenticated `Workspace`. | +| V. Explicit Over Implicit | PASS | `re_sign_on_expiry` defaults to True but is overridable. `max_files` defaults to 500 but is configurable. `retention_days=None` triggers discovery; explicit value bypasses it. `include_mixpanel_events=False` by default — opt-in to the extra round trip. No silent retries on 404 (end-of-recording sentinel). | +| VI. Unix Philosophy | PASS | `mp replays fetch -o file.json` writes raw rrweb JSON suitable for piping into the rrweb JS player or jq. `mp replays analyze --format json` emits structured action lists for downstream tools. `--reveal-signed-urls` emits the credential to stdout AND a warning to stderr. Exit codes follow the existing `ExitCode` enum. | +| VII. Secure by Default | PASS WITH JUSTIFICATION | Signed URLs are bearer credentials. `SignedReplay.__repr__` and `__str__` mask the `query_string`. The library NEVER logs the credential. The `SESSION_RECORDING_SENSITIVE_DATA` 403 maps to a distinct `SessionReplayAccessError` with actionable details. The CLI `--reveal-signed-urls` flag is the single opt-in path to credential disclosure; it emits a stderr warning every time. See [Complexity Tracking](#complexity-tracking) for the `to_dict()` serialization justification (full credential preserved but flagged). | + +**Gate Result**: PASS. Principle VII needs the explicit `to_dict()` justification because we preserve the full credential on serialization (the user chose to serialize; we cannot meaningfully prevent it once `.query_string` is accessed). No actual violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/044-session-replay/ +├── plan.md # This file +├── spec.md # Feature specification (created by /speckit-specify) +├── research.md # Phase 0 output (this command) +├── data-model.md # Phase 1 output (this command) +├── quickstart.md # Phase 1 output (this command) +├── contracts/ # Phase 1 output (this command) +│ ├── python-api.md # Workspace methods + result types + exception hierarchy +│ ├── cli-commands.md # `mp replays {list, events, sign, fetch, analyze, for-user}` +│ └── error-messages.md # Stable error catalog (sensitive-data 403, signed-URL expiry, missing extras) +├── checklists/ +│ └── requirements.md # Spec quality checklist (created by /speckit-specify) +└── tasks.md # Phase 2 output (via /speckit-tasks) +``` + +### Source Code (repository root) + +```text +src/mixpanel_headless/ +├── workspace.py # MODIFIED (Phase 1: +4 methods; Phase 2: +5 methods) +│ # Phase 1: list_replays, events_for_replay, +│ # events_for_replays, sign_replay, +│ # sign_replays, fetch_replay, stream_replay +│ # Phase 2: fetch_replays, replays_for_user, analyze_replay +├── types.py # MODIFIED (Phase 1: ReplaySummary, SignedReplay, ReplayEvent, Replay; +│ # Phase 2: UserAction, ReplayBundle) +├── exceptions.py # MODIFIED (Phase 1) — add SessionReplayError, +│ # SessionReplayAccessError, SignedURLExpiredError, ReplayNotFoundError +├── __init__.py # MODIFIED — add 10 new public exports +├── _internal/ +│ ├── services/ +│ │ └── replays.py # NEW (Phase 1) — ReplaysService: orchestrates sign + fetch + discovery +│ ├── api_client.py # MODIFIED (Phase 1) — add sign_replays() method; +│ │ # wire the SESSION_RECORDING_SENSITIVE_DATA 403 mapping +│ └── replays/ # NEW SUBPACKAGE (Phase 2) +│ ├── __init__.py +│ ├── rrweb_analyzer.py # NEW (Phase 2) — VENDORED from analytics/backend/replays/rrweb_analyzer.py +│ │ # DOMTracker + EventAnalyzer + MarkdownReporter, pure stdlib +│ ├── labels.py # NEW (Phase 2) — default_label_fn, selector_label_fn, url_normalizer +│ └── aggregators.py # NEW (Phase 2) — top_clicks, rage_clicks, +│ # long_pauses, error_sessions, real_clicks +└── cli/ + ├── main.py # MODIFIED (Phase 1) — register replays_app in _register_commands() + └── commands/ + └── replays.py # NEW (Phase 1 core + Phase 2 analyze/for-user) — Typer commands + +tests/ +├── unit/ +│ ├── test_replays_service.py # NEW (Phase 1) — mocked HTTP, request shape, error mapping +│ ├── test_types_replay_summary.py # NEW (Phase 1) — dataclass shape, from-Insights conversion +│ ├── test_types_signed_replay.py # NEW (Phase 1) — __repr__ masking, expires_at, is_expired +│ ├── test_workspace_replays.py # NEW (Phase 1) — Workspace method tests (mocked service) +│ ├── test_rrweb_analyzer.py # PORTED (Phase 2) from analytics/backend/replays/test_rrweb_analyzer.py +│ ├── test_replay_labels.py # NEW (Phase 2) — default + selector label stability +│ ├── test_types_replay.py # NEW (Phase 2) — Replay DataFrame projections, mode-aware df +│ └── test_types_replay_bundle.py # NEW (Phase 2) — ReplayBundle aggregations, filters, lazy props +├── pbt/ +│ ├── test_cdn_walker_pbt.py # NEW (Phase 1) — file-numbering walker invariants +│ ├── test_replay_labels_pbt.py # NEW (Phase 2) — label_fn stability across DOM perturbations +│ └── test_types_replay_bundle_pbt.py # NEW (Phase 2) — DataFrame projection invariants +├── integration/ +│ └── test_replays_live.py # NEW (Phase 1) — @pytest.mark.live: list / sign / fetch a known replay +└── fixtures/ + └── rrweb/ + ├── sample-replay-001.json # NEW (Phase 1) — login + one click + navigation + ├── sample-replay-002.json # NEW (Phase 2) — multi-page, mixed interactions + ├── sample-replay-003.json # NEW (Phase 2) — console errors, rage clicks, dead clicks, long pauses + └── sample-bundle-fixture.py # NEW (Phase 2) — builds a deterministic 10-replay ReplayBundle +``` + +**Structure Decision**: Single-project Python library layout (Option 1). The feature extends three existing surfaces (`workspace.py` Python facade, `types.py` result classes, `cli/commands/` Typer commands) and adds one new internal subpackage (`_internal/replays/`) for the vendored analyzer, label functions, and aggregators. No new top-level layout; all changes nest under existing module roots. + +## Complexity Tracking + +> Fill ONLY if Constitution Check has violations that must be justified + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| `SignedReplay.to_dict()` preserves the full bearer credential | Round-tripping a `SignedReplay` through serialization (e.g. caching to disk for testing, sending across an IPC boundary, pickling for a multiprocessing worker pool) requires the credential to survive. Users opting into serialization have already chosen to accept the disclosure surface; redacting in `to_dict()` would silently break the contract that `from_dict(to_dict(x)) == x`. | Drop the credential on serialization: breaks round-trip semantics and forces every caller who legitimately needs to persist a signed URL to re-sign. Forcing them to re-sign is a worse outcome than including a top-level `_warning` marker noting the bearer nature. | +| Vendored analyzer (~600 LoC ports from analytics monorepo into `_internal/replays/rrweb_analyzer.py`) | The analyzer is the load-bearing piece for `Replay.actions` and `ReplayBundle.actions_df`. Pulling it in as a runtime dependency on the analytics monorepo is impossible (private repo, cross-repo coupling). Pulling it in as a separate PyPI package would create a 3-way release dance with no clear owner. | Direct dependency on the analytics monorepo: rejected (private). Separate PyPI package: rejected (release-coordination cost). Re-implement from scratch: rejected (duplicate work, drift risk). Vendoring with a documented source link and periodic diff is the standard pattern for this kind of shared internal logic. | +| Phase 2 ships analyzer + ReplayBundle in one PR (~1,500 LoC) instead of splitting them | The analyzer is what populates `Replay.actions`, which is what `ReplayBundle.actions_df` reads. Shipping them separately means either the analyzer ships first with no consumer (dead code in the merged branch) OR the bundle ships first with empty action streams (incorrect / misleading API). | Two separate PRs: the dependency chain forces a coupled review anyway; the cognitive cost of splitting outweighs the per-PR review cost. The PRs would be ~700 LoC and ~800 LoC, neither small enough to warrant the split. | diff --git a/specs/044-session-replay/quickstart.md b/specs/044-session-replay/quickstart.md new file mode 100644 index 00000000..ccb51f22 --- /dev/null +++ b/specs/044-session-replay/quickstart.md @@ -0,0 +1,265 @@ +# Quickstart: Session Replay + +**Feature**: 044-session-replay +**Audience**: New users exploring the session-replay surface; reviewers smoke-testing before merge. + +This walkthrough exercises every documented user story (P1–P3) from spec.md. Treat it as the merge-gate recipe. + +--- + +## Story 1 (P1) — Discover and pull a user's recent replays + +### 1.1 Discover + +```python +import mixpanel_headless as mp + +ws = mp.Workspace.use(account="acme-corp", project=3713224) + +summaries = ws.list_replays( + distinct_id="user-42", + from_date="2026-05-20", + to_date="2026-05-27", +) +print(f"Found {len(summaries)} replays") +for s in summaries: + print(f" {s.replay_id} started {s.start_time} retention {s.retention_days}d") +``` + +**Expected output** (when user-42 has 3 sessions in the window): +``` +Found 3 replays + r-19221397401184 started 1716192721000 retention 30d + r-19222483017720 started 1716279127000 retention 30d + r-19223568634256 started 1716365533000 retention 30d +``` + +### 1.2 Sign + +```python +signed = ws.sign_replays([s.replay_id for s in summaries]) +print(signed[0]) # __repr__ masks the bearer credential +``` + +**Expected output**: +``` +SignedReplay(replay_id='r-19221397401184', url='https://cdn.mxpnl.com/srr-us/abc.../', query_string='', env='prod', signed_at=1716365822.7) +``` + +### 1.3 Fetch raw bytes + +```python +replay = ws.fetch_replay("r-19221397401184") +print(f"{len(replay.rrweb_events)} events, duration {replay.duration_seconds:.1f}s") + +# Write rrweb-player-compatible JSON +import json +from pathlib import Path + +Path("recording.json").write_text( + json.dumps(replay.to_rrweb_player_json()) +) +``` + +**Expected output**: +``` +4823 events, duration 492.1s +``` + +### 1.4 Stream (for large replays) + +```python +for i, event in enumerate(ws.stream_replay("r-19221397401184")): + if i == 0: + print(f"first event at t={event['timestamp']}") + if i >= 99: + print("...stopping after 100 events") + break +``` + +### 1.5 CLI equivalent + +```bash +# Discover +mp replays list --user user-42 --from 2026-05-20 --to 2026-05-27 + +# Pull bytes +mp replays fetch r-19221397401184 -o recording.json + +# Play in a browser +# (open an HTML page that imports rrweb-player and loads recording.json) +``` + +--- + +## Story 2 (P2) — Behavioral analysis across many replays + +### 2.1 Build a bundle + +```python +bundle = ws.replays_for_user( + distinct_id="user-42", + from_date="2026-05-20", + to_date="2026-05-27", + include_mixpanel_events=True, +) +print(bundle.df.head()) # default projection: sessions_df +``` + +**Expected**: a `pandas.DataFrame` with one row per replay and the documented session-level columns (`replay_id`, `duration_s`, `n_events`, `n_actions`, ...). + +### 2.2 Top-N aggregations + +```python +print("Top 5 most-clicked elements:") +print(bundle.top_clicks(n=5)) + +print("\nRage-click sessions:") +print(bundle.rage_clicks(threshold=3, window_ms=1000)) + +print("\nSessions with console errors:") +print(bundle.error_sessions().sessions_df[["replay_id", "n_errors"]]) +``` + +### 2.3 Chainable filters + +```python +# Funnel-like: sessions that hit /checkout AND took longer than 60s +checkout_sessions = ( + bundle + .where(contains_url="/checkout") + .filter(lambda r: r.duration_seconds > 60) +) +print(f"{len(checkout_sessions.replays)} long checkout sessions") + +# Deterministic sample for manual review +sample = checkout_sessions.sample(n=3, seed=42) +for r in sample.replays: + print(r.summary_markdown[:200], "...") +``` + +### 2.4 Two-bundle comparison + +```python +# Build a second bundle for users who converted +converters = ws.replays_for_user(distinct_id="user-99", from_date="2026-05-20", to_date="2026-05-27") + +diff = bundle.compare(converters) +print(diff.head(10)) # action-frequency diff +``` + +--- + +## Story 3 (P2) — CLI walkthrough + +### 3.1 Markdown timeline for a single replay + +```bash +mp replays analyze r-19221397401184 +``` + +**Expected stdout** (excerpt): +```markdown +# Session r-19221397401184 — 8m 12s — user-42 + +## Timeline + +- 14:32:01 navigate to `https://acme.com/login` +- 14:32:04 click `button "Sign in"` +- 14:32:06 input `input[type=email]` (12 chars) +... +``` + +### 3.2 Full bundle for a user, written to disk + +```bash +mp replays for-user user-42 --from 2026-05-20 --to 2026-05-27 \ + --include analyze \ + --out-dir ./replays/ + +ls ./replays/ +# r-19221397401184-summary.md +# r-19222483017720-summary.md +# r-19223568634256-summary.md +# index.json +``` + +### 3.3 Bearer-credential handling + +```bash +# Redacted by default +mp replays sign r-19221397401184 +# {"replay_id": "r-19221397401184", "url": "...", "query_string": "", ...} + +# Opt into disclosure (with warning) +mp replays sign r-19221397401184 --reveal-signed-urls 2>/tmp/warning.txt +cat /tmp/warning.txt +# warning: signed URLs are bearer credentials valid for ~5 minutes. Treat them +# like session tokens — do not paste into chat, logs, or version control. +``` + +--- + +## Smoke-test script (merge gate) + +The full quickstart above is the manual smoke test. The reduced version that lives in CI: + +```bash +# Phase 1 smoke test (after Phase 1 PR merges) +uv run python -c " +import mixpanel_headless as mp +ws = mp.Workspace.use() +s = ws.list_replays(distinct_id='$KNOWN_USER', from_date='2026-05-20', to_date='2026-05-27') +assert len(s) > 0, 'no replays found for known user' +r = ws.fetch_replay(s[0].replay_id) +assert len(r.rrweb_events) > 0, 'replay had no events' +print(f'OK: fetched {len(r.rrweb_events)} events for {r.replay_id}') +" + +# Phase 2 smoke test (after Phase 2 PR merges) +uv run python -c " +import mixpanel_headless as mp +ws = mp.Workspace.use() +b = ws.replays_for_user('$KNOWN_USER', from_date='2026-05-20', to_date='2026-05-27') +assert len(b.replays) > 0 +assert len(b.sessions_df) == len(b.replays) +assert len(b.actions_df) > 0 +print(f'OK: bundle of {len(b.replays)} replays, {len(b.actions_df)} actions') +" + +# CLI smoke test +mp replays list --user "$KNOWN_USER" --from 2026-05-20 --to 2026-05-27 --format json | jq length +mp replays sign "$KNOWN_REPLAY_ID" --format json | jq -r '.[0].query_string' +# expected: +``` + +--- + +## Performance verification + +The following targets must hold on a typical broadband connection. Failing any of these indicates a regression: + +| Operation | Target | Measurement | +|-----------|--------|-------------| +| `list_replays(7-day window)` | ≤ 2 s | Wall-clock from call to return | +| `sign_replays(100 IDs)` | ≤ 2 s | Single round-trip | +| `fetch_replay(30 MB replay)` | ≤ 5 s | Includes sign + walk + parse | +| `stream_replay` first event | ≤ 1 s | Time-to-first-yield | +| `ReplayBundle.actions_df` (100 replays) | ≤ 10 s | End-to-end from `replays_for_user` | + +--- + +## Security verification + +Before each merge, audit the transcript for credential leaks: + +```bash +# Grep your test output for any unredacted signed URLs +mp replays list --user "$KNOWN_USER" --from 2026-05-20 --to 2026-05-27 -v 2>&1 \ + | grep -E 'Signature=|URLPrefix=|Expires=' && echo "LEAK DETECTED" || echo "OK" + +mp replays fetch "$KNOWN_REPLAY_ID" -o /tmp/r.json -v 2>&1 \ + | grep -E 'Signature=|URLPrefix=|Expires=' && echo "LEAK DETECTED" || echo "OK" +``` + +Both commands must report `OK`. diff --git a/specs/044-session-replay/research.md b/specs/044-session-replay/research.md new file mode 100644 index 00000000..07cbecf4 --- /dev/null +++ b/specs/044-session-replay/research.md @@ -0,0 +1,174 @@ +# Phase 0 Research: Session Replay + +**Feature**: 044-session-replay +**Date**: 2026-05-27 +**Status**: Complete — no NEEDS CLARIFICATION markers in plan.md remain. + +The source design (`context/session-replay-plan.md`) already settled the load-bearing decisions through code-archaeology against the analytics monorepo, Mixpanel's MCP server, and the rrweb upstream. This document records the decisions, their rationale, and the rejected alternatives so reviewers can audit without re-deriving. + +--- + +## R-1. Discovery via Insights Query API, not legacy Segmentation + +**Decision**: `list_replays` issues a single `Workspace.query()` call against `$mp_session_record` events, grouped on `$mp_replay_id` AND `$mp_replay_retention_period` AND `$time`. Returns shape into `list[ReplaySummary]`. + +**Rationale**: +- The Phase 029 typed Insights surface (`Workspace.query()`) already supports the required grouping and is the project's standard query path. +- `$mp_replay_retention_period` is the source of truth for per-replay retention (set at ingestion). Grouping on it makes the retention value available without a second round-trip. +- The Insights group-by limit is high enough (>5) to add optional event_properties for `events_for_replay` without contortions. + +**Alternatives considered**: +- **Legacy Segmentation API**: rejected — Phase 029 deprecated it for typed callers and the grouping API there is weaker. +- **Direct enumeration via `/api/2.0/events`**: rejected — no group-by support, wastes bandwidth on event bodies. +- **Cohort-driven discovery (`replays_for_cohort`)**: deferred — adds a join layer with no clear demand yet. + +--- + +## R-2. Signed CDN access uses the bulk endpoint always + +**Decision**: All signing goes through `POST /app/projects//replays/sign/bulk`, even single-replay signing. `sign_replay(id)` is a thin wrapper that passes a one-element list to `sign_replays([id])`. + +**Rationale**: +- Reduces surface area: one endpoint binding, one error-mapping path, one set of tests. +- The bulk endpoint accepts single-element lists without overhead. +- Future batch optimizations (e.g. signing a whole bundle's worth of IDs upfront) need no extra plumbing. + +**Alternatives considered**: +- **Wrap both `/replays/sign` and `/replays/sign/bulk`**: rejected — duplicates code, doubles the error-mapping surface for no caller-visible benefit. +- **Issue parallel single-replay sign requests for bundles**: rejected — N round-trips where 1 suffices. + +--- + +## R-3. CDN file walker terminates on first 404, bounded by `max_files` + +**Decision**: The CDN walker fetches files `0000-N.json`, `0001-N.json`, ... in parallel batches of 50. The first 404 in numeric order is the end-of-recording sentinel; the walker terminates cleanly without retry. `max_files=500` (default) is a hard upper bound in case the sentinel is somehow missed. + +**Rationale**: +- 404 as end-of-recording is the documented contract from `go/src/mixpanel.com/ingestion/api/handlers/record_session.go`. Retrying it is wrong. +- Parallel batches of 50 match Mixpanel's MCP server's existing CDN concurrency; matches their server-side capacity planning. +- `max_files=500` covers 99.99% of real replays (a 5-second replay fits in 1 file; a 90-minute replay fits in ~50). 500 is the safety belt. + +**Alternatives considered**: +- **Treat 404 as transient, retry with exponential backoff**: rejected — masks the sentinel and burns CDN bandwidth. +- **Walk serially, one file at a time**: rejected — multiplies wall-clock time by 50× for large replays. +- **No `max_files` bound**: rejected — a corrupted recording (e.g. file `0042-30.json` exists but `0043-30.json` was never uploaded then someone uploaded `1000-30.json` by accident) could runaway-loop the walker. + +--- + +## R-4. Streaming variant re-signs on expiration by default + +**Decision**: `stream_replay(replay_id, re_sign_on_expiry=True)` catches a 403 with an "expired" signature reason mid-stream, re-signs the prefix transparently, and continues. Setting `re_sign_on_expiry=False` propagates a distinct `SignedURLExpiredError`. + +**Rationale**: +- A 5-minute TTL is enough for `fetch_replay` (signs + fetches in immediate succession) but tight for `stream_replay` consumers that process events as they arrive (e.g. an LLM digesting a 1-hour session). +- Re-signing is cheap (one API call) and the prefix URL is stable across re-signs (only the `query_string` rotates). +- Power users (e.g. a deterministic test harness, a strict-budget agent) can disable to surface the timing issue. + +**Alternatives considered**: +- **Never re-sign, always raise**: rejected — surfaces a transient timing issue as a hard failure for the common case. +- **Always re-sign silently, no opt-out**: rejected — hides the timing issue from callers who legitimately want determinism. +- **Pre-extend TTL via a custom signing endpoint**: rejected — would require server-side changes to Mixpanel's signing service. + +--- + +## R-5. `SignedReplay.__repr__` masks the credential + +**Decision**: `SignedReplay.__repr__` and `__str__` replace the `query_string` field with ``. The library NEVER logs the credential at any level. The CLI defaults to redacted output; `--reveal-signed-urls` is the single opt-in. + +**Rationale**: +- Coding agents routinely paste tool outputs into LLM transcripts. A default `repr` that includes a 5-minute bearer credential turns every `print(sign_replays(...))` into a leak vector. +- Mixpanel's own MCP server treats signed URLs as bearer credentials. Headless should match. +- The `--reveal-signed-urls` flag is a deliberate friction-point: every use emits a stderr warning naming the bearer-credential semantics. Hard to miss in transcripts. + +**Alternatives considered**: +- **Default to full disclosure, document the security note**: rejected — documentation does not protect against accidental leaks; defaults do. +- **Encrypt the `query_string` field, decrypt only on URL construction**: rejected — adds key-management surface for a 5-minute credential. +- **Refuse to construct `SignedReplay` in dataclass form, only return URL strings**: rejected — loses the `expires_at` / `is_expired` accessors and breaks the dataclass-shaped API expected by typed callers. + +--- + +## R-6. Vendored rrweb analyzer (pure-stdlib, no third-party deps) + +**Decision**: Port `analytics/backend/replays/rrweb_analyzer.py` (~600 LoC) into `_internal/replays/rrweb_analyzer.py`. Mark the file with a docstring naming the upstream source and a comment block on the divergence policy. + +**Rationale**: +- The analyzer is the load-bearing piece for `Replay.actions` and `ReplayBundle.actions_df`. Without it, "fetch the bytes" is the only shippable capability. +- Cross-repo dependency on the analytics monorepo is impossible (private repo, no public release surface). +- A separate PyPI package would create a 3-way release dance (analytics monorepo → standalone package → `mixpanel-headless`) with no clear owner. +- The analyzer is pure stdlib (no `numpy`, no `pydantic`, no `httpx`). Vendoring adds zero install weight. + +**Alternatives considered**: +- **Standalone PyPI package**: rejected — release coordination cost, no clear owner. +- **Direct dependency on analytics monorepo**: rejected — private, would require publishing the monorepo or a sub-tree. +- **Re-implement from scratch**: rejected — duplicate work, drift risk, defect risk against a battle-tested implementation. +- **Call into the MCP server as a sidecar**: rejected — adds a runtime dependency and a network hop for what is fundamentally a pure compute task. + +**Drift mitigation**: explicit `# Vendored from analytics/backend/replays/rrweb_analyzer.py @ ` in the module docstring + a quarterly diff check (manual or via a CI job that fetches the upstream and runs `diff`). + +--- + +## R-7. Default activity label: `f"{action}:{tag_name}@{normalized_url}"` + +**Decision**: The default `label_fn` produces stable activity labels of shape `f"{action}:{tag_name}@{normalized_url}"`. URL normalization strips query strings and replaces numeric path segments with `:id` (`/users/12345/profile` → `/users/:id/profile`). A built-in `selector_label_fn(attr="data-testid")` is provided for projects that tag interactive elements. + +**Rationale**: +- Process mining requires stable labels: same semantic action must produce the same label across sessions. +- `tag_name` (e.g. `button`, `input[type=email]`) is coarse enough to align across A/B tests and i18n drift. +- `normalized_url` collapses noise (query strings, numeric IDs) that would otherwise fragment the label space. +- `data-testid` (or equivalent) is the SDK-side best practice for stable element identification; the built-in label fn rewards projects that adopt it. + +**Alternatives considered**: +- **Use the rrweb node ID as the label**: rejected — node IDs are per-session, never align across sessions. +- **Use the element's text content**: rejected — fragments under i18n, A/B tests, dynamic content. +- **Use the full CSS selector path**: rejected — fragile under DOM drift (a parent div rename invalidates every descendant's label). +- **Hash the entire element subtree**: rejected — opaque to humans reading the labels; debugging becomes guesswork. + +**Escape valve**: every method that emits activity labels (`find_pattern`) accepts `label_fn=` for caller-controlled labeling. The default is a sensible starting point, not a forced choice. + +--- + +## R-8. Insights group-by limit (5 properties) enforced client-side + +**Decision**: `events_for_replay(replay_id, event_properties=[...])` validates `len(event_properties) <= 5` before constructing the query. Raises `ValueError` with a clear message naming the limit. + +**Rationale**: +- The Insights API caps group-by at 5 keys (counting `$time`, `$event_name`, `$mp_replay_id` plus user-supplied properties). +- A server-side 400 returns a generic "too many group-by keys" message. Client-side validation surfaces the issue with the actual count and the actual list of properties. +- Saves a round trip on the failure path. + +**Alternatives considered**: +- **Defer to the server's 400**: rejected — slower failure, less informative error. +- **Auto-truncate to the first 5**: rejected — silent data loss, violates Explicit Over Implicit. +- **Group differently to bypass the limit**: rejected — the limit exists for a reason (query cost); working around it would surprise the user with slower queries. + +--- + +## R-9. Bundle memory budget documented, not enforced + +**Decision**: Documentation states that `ReplayBundle` targets hundreds of replays (memory ~2 MB per replay in `actions_df`). No runtime enforcement of bundle size. Callers exceeding the budget are directed to `stream_replay` per replay + incremental aggregation. + +**Rationale**: +- Hard caps would surprise legitimate large-bundle callers (e.g. a behavioral scientist with a curated 10,000-replay corpus). +- Memory budgets are user-context-specific (a 64 GB workstation can comfortably hold 10× what a 4 GB CI runner can). +- Documentation + an obvious alternative path (streaming) is the standard Python-library pattern. + +**Alternatives considered**: +- **Hard cap at 1,000 replays**: rejected — arbitrary, surprises power users. +- **Soft warning above N replays**: rejected — log noise for callers who know what they are doing. +- **Lazy DataFrame materialization with a row-count budget**: rejected — adds complexity to the bundle internals for a problem better solved at the call site. + +--- + +## R-10. Phase boundaries match shippable PRs, not feature completeness + +**Decision**: Phase 1 ships discovery + signed access + per-replay fetch with empty `Replay.actions` (analyzer not yet shipped). Phase 2 ships the analyzer, populating `Replay.actions` and adding `ReplayBundle`. + +**Rationale**: +- Each phase delivers caller-visible value independently. Phase 1 alone lets users pull raw bytes for the rrweb JS player. Phase 2 adds structured behavioral data. +- Phase 1 is the most uncertain (new endpoints, new error mapping, new bearer-credential handling). Shipping it alone lets reviewers focus on that surface without the analyzer's 1,500-LoC noise. +- Phase 2 builds on Phase 1's foundations; reviewers can assume the discovery / signing / fetching layer is already approved. + +**Alternatives considered**: +- **Single mega-PR (~2,700 LoC)**: rejected — review burden, regression risk. +- **One PR per file**: rejected — review thrash, no logical breakpoints. +- **Phase 2 splits analyzer and bundle**: rejected (see Complexity Tracking in plan.md) — the analyzer is the bundle's data source; shipping separately creates dead code or incorrect API. diff --git a/specs/044-session-replay/spec.md b/specs/044-session-replay/spec.md new file mode 100644 index 00000000..debcfffd --- /dev/null +++ b/specs/044-session-replay/spec.md @@ -0,0 +1,180 @@ +# Feature Specification: Session Replay for `mixpanel-headless` + +**Feature Branch**: `044-session-replay` +**Created**: 2026-05-27 +**Status**: Draft +**Input**: User description: "@context/session-replay-plan.md" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Discover and pull a user's recent replays (Priority: P1) + +A UX researcher, support engineer, or product analyst knows a specific Mixpanel user is having trouble and wants to see what that user did. They use `mixpanel-headless` to discover all of that user's session replays in a date window, sign them for CDN access, and pull the raw recording bytes — either for offline playback in the rrweb JS player or for downstream programmatic analysis. + +**Why this priority**: This is the headline use case. Without it the feature delivers nothing. The plan's Phase 1 ships exactly what this story requires (discovery + signed access + per-replay fetch) and is independently shippable. + +**Independent Test**: Given a known active `distinct_id` and a 7-day window: `Workspace.list_replays` returns at least one summary; `sign_replay` produces a valid signed URL; the CDN file behind that URL contains rrweb events; `fetch_replay` returns a `Replay` whose raw event list serializes to a JSON file playable directly in `rrweb-player`. + +**Acceptance Scenarios**: + +1. **Given** a `distinct_id` with 3 recorded sessions in the last 7 days, **When** the caller invokes `list_replays(distinct_id=..., from_date=..., to_date=...)`, **Then** 3 summaries are returned, each carrying the correct retention period read from `$mp_replay_retention_period`. +2. **Given** an active session of recording bytes on the CDN, **When** the caller invokes `fetch_replay(replay_id)`, **Then** a `Replay` is returned with at least one rrweb event and a non-zero duration. +3. **Given** the same fetch via `stream_replay`, **When** the caller iterates the generator, **Then** the first rrweb event is yielded within ~1 second and memory stays bounded to a single in-flight batch. +4. **Given** a project flagged `SESSION_RECORDING_SENSITIVE_DATA` and a caller without sensitive-data access, **When** `sign_replay` is called, **Then** a distinct `SessionReplayAccessError` is raised naming the project and the missing permission — not a generic 403. +5. **Given** a replay missing `$mp_replay_retention_period` (older SDK), **When** discovery runs, **Then** the summary carries `retention_days=30` and a structured warning is emitted naming the replay ID. + +--- + +### User Story 2 — Behavioral analysis across many replays (Priority: P2) + +A product manager wants to know where users get stuck in a flow. They pull all replays for users who hit a particular event, then ask the library which click sequences are most common, which clicks were "dead" (no DOM response), which sessions ended in console errors, and which contained rage-click patterns. They iterate by filtering, sampling, and joining Mixpanel events into the bundle without re-fetching. + +**Why this priority**: This is the high-leverage type — converting raw recordings into structured behavioral data. Phase 2 of the plan ships exactly this and is independently shippable on top of Phase 1. + +**Independent Test**: A `ReplayBundle` built from 10 fixture rrweb streams exposes five long-format DataFrame projections (sessions, actions, events, mixpanel, elements). The aggregations (`top_clicks`, `rage_clicks`, `long_pauses`, `error_sessions`) return non-empty results for the appropriate fixtures. The chainable filters (`filter`, `where`, `find_pattern`, `head`, `sample`) return new bundles that are proper subsets of the original, with no shared mutable state. + +**Acceptance Scenarios**: + +1. **Given** a bundle of 10 replays where one fixture has 5 consecutive clicks on the same button in <1 second, **When** `rage_clicks(threshold=3, window_ms=1000)` is called, **Then** exactly that replay's rage-click rows appear in the result. +2. **Given** a bundle and a user-defined predicate, **When** `filter(predicate)` is called, **Then** a new bundle is returned containing exactly the subset matching the predicate; the original is unchanged. +3. **Given** a bundle where a user's click fires both a focus and a click interaction, **When** `top_clicks(n=5)` is called, **Then** a frequency-ordered DataFrame of click targets is returned that counts each user click once (focus-only interactions excluded). +4. **Given** two bundles (e.g. converters vs non-converters), **When** `bundle_a.compare(bundle_b)` is called, **Then** a DataFrame is returned showing action-frequency differences across the two bundles. + +--- + +### User Story 3 — Pull a user's activity from the command line (Priority: P2) + +A support engineer pasting context into a chat does not want to write Python. They want one `mp` command that takes a user ID and produces a readable markdown summary of recent sessions, or a JSON dump for another tool to consume. + +**Why this priority**: CLI sugar is independently valuable — same backend, different surface, different audience. Ships across Phase 1 (basic commands) and Phase 2 (`analyze`, `for-user`). + +**Independent Test**: `mp replays list --user X --from D --to D` produces a table. `mp replays analyze REPLAY_ID` produces a markdown timeline on stdout. `mp replays for-user X --from D --to D --include analyze --out-dir DIR` writes per-replay markdown files. `mp replays sign R` redacts the bearer credential by default; `--reveal-signed-urls` includes it but writes a stderr warning every time. + +**Acceptance Scenarios**: + +1. **Given** an analyst running `mp replays for-user user-42 --from 2026-05-20 --to 2026-05-27 --include analyze --out-dir ./replays/`, **When** the command completes, **Then** per-replay markdown timelines are written to disk and a summary line is printed to stdout. +2. **Given** a request to sign a replay without `--reveal-signed-urls`, **When** the command emits its result in any output format, **Then** the bearer credential is replaced with a redaction marker. +3. **Given** a request to sign with `--reveal-signed-urls`, **When** the command runs, **Then** the bearer credential is printed in full AND a warning is written to stderr stating that signed URLs are bearer credentials. +4. **Given** `mp replays fetch -o file.json`, **When** the command completes, **Then** the output is a JSON array of rrweb events directly compatible with the rrweb JS player (verified by round-tripping into `rrweb-player`). + +--- + +### Edge Cases + +- **Empty discovery**: `list_replays` returns an empty list, never raises. Caller decides how to surface "no replays found". +- **Missing retention property**: `$mp_replay_retention_period` absent on the discovered event (older SDK) → default to 30 days, emit structured warning naming the replay ID and hinting at SDK upgrade. +- **Signed URL expired mid-stream**: slow `stream_replay` consumer outlives the 5-minute TTL. Default `re_sign_on_expiry=True` re-signs and continues; `False` propagates a clear `SignedURLExpiredError`. +- **404 before `max_files`**: terminate cleanly. 404 is the documented end-of-recording sentinel and MUST NOT be retried. +- **Runaway CDN walker**: `max_files` (default 500) bounds the walker if the 404 sentinel is somehow missed. +- **Sensitive-data 403**: maps to `SessionReplayAccessError` with structured `details = {"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA"}` and an actionable message naming the missing permission. +- **Mobile replays**: discoverable (the discovery layer is platform-agnostic), but the bytes / analyzer layers are web-only. The library MUST either skip mobile events the analyzer cannot interpret, or raise a clear "mobile not yet supported" error. +- **Insights group-by limit**: caller asks for more than 5 event_properties on `events_for_replay` → explicit error before the round-trip rather than a Mixpanel API 400. +- **Very large bundle**: `ReplayBundle` is for hundreds, not millions. Documented memory budget (~2 MB per replay in `actions_df`). For 100k+ replays, callers fall back to `stream_replay` + incremental aggregation. +- **Replay ID with zero CDN files**: signed URL points to a prefix where `0000-N.json` is already 404 → raise `ReplayNotFoundError` naming the replay ID (replay aged out of retention or was never recorded). + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Discovery (Phase 1) + +- **FR-001**: System MUST let callers list a user's replays by `distinct_id` and date window, returning lightweight summaries. +- **FR-002**: System MUST let callers hydrate summaries for an explicit list of `replay_ids` without requiring a `distinct_id`. +- **FR-003**: Discovery MUST use the existing typed Insights query path (`Workspace.query()`) against `$mp_session_record`, grouping on `$mp_replay_id` and `$mp_replay_retention_period` and reading each replay's `start_time` from a `min` aggregation on `$time` (`math="min", math_property="$time"`) — never the legacy Segmentation API. (A `min($time)` aggregation returns one compact min-timestamp per replay; grouping on `$time` directly would emit a per-second bucket per event and risk the Insights result cap for no benefit.) Discovery MUST parse the raw `result.series` nested dict, not the lossy single-level `result.df` projection. +- **FR-004**: Discovery MUST return an empty list when no replays are found in range. It MUST NOT raise. +- **FR-005**: Per-replay retention MUST be read from `$mp_replay_retention_period` on the discovered event. When missing, retention MUST default to 30 days AND a structured warning MUST be emitted naming the replay ID. +- **FR-006**: System MUST let callers fetch Mixpanel events filtered to a replay's time window — single (`events_for_replay`) AND batched (`events_for_replays`) — optionally with up to 5 event properties as additional group keys. + +#### Signed CDN access (Phase 1) + +- **FR-007**: System MUST sign one or many replays via the bulk endpoint, returning time-bounded handles (TTL ~5 minutes). +- **FR-008**: Signed-URL handles MUST mask their bearer credential in `repr` AND `str` so default Python logging cannot leak it accidentally. +- **FR-009**: System MUST NOT log the bearer credential at any log level. URL prefix (without query string) MAY be logged at DEBUG level only. +- **FR-010**: A 403 response indicating the `SESSION_RECORDING_SENSITIVE_DATA` project flag MUST raise a distinct `SessionReplayAccessError` carrying structured `details = {"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA", "permission_required": "sensitive_data_replay"}` and an actionable user-facing message naming the missing permission. (This is the authoritative location for the exception contract; FR-045 references it.) +- **FR-011**: Serialization paths on signed-URL handles (e.g. `to_dict`) MUST preserve the full credential AND include a top-level warning marker noting the bearer nature. + +#### CDN fetching (Phase 1) + +- **FR-012**: System MUST fetch a replay by signing then walking the CDN files in parallel batches, concatenating events, sorting by timestamp, and returning a populated `Replay`. +- **FR-013**: System MUST also offer a streaming variant that yields rrweb events one at a time with bounded memory (one batch in flight at a time, batches yielded in timestamp order). +- **FR-014**: The CDN walker MUST terminate cleanly on the first 404 (end-of-recording sentinel) and MUST bound itself by a configurable `max_files` parameter (default 500). +- **FR-015**: The streaming variant MUST optionally re-sign on a 403 indicating signature expiration (default ON). When OFF, it MUST raise a distinct `SignedURLExpiredError`. +- **FR-016**: Per-replay fetch MUST accept an explicit `retention_days` parameter to bypass the discovery round-trip. When absent, it MUST discover retention via the same Insights query path as `list_replays`. +- **FR-017**: Per-replay fetch MUST accept an `include_mixpanel_events` flag that triggers a follow-up Insights query to populate the `Replay.mixpanel_events` list. + +#### Single-replay analysis (Phase 1 raw, Phase 2 normalized) + +- **FR-018**: `Replay` MUST expose the raw rrweb event list AND lazy pandas DataFrame projections for raw events, normalized actions, Mixpanel events (when populated), and page navigations. +- **FR-019**: `Replay` MUST default its primary DataFrame view to the normalized actions projection. +- **FR-020**: `Replay` MUST expose convenience accessors: `duration_seconds`, `page_path()`, `errors`, `clicks_on(predicate)`, `summary_markdown`, and `to_rrweb_player_json()`. +- **FR-021**: In Phase 1 (no vendored analyzer yet), `Replay.actions` MAY be empty and the analyzer-dependent accessors MAY raise a clear "analyzer not yet shipped, ships in Phase 2" error. + +#### Cross-session analysis (Phase 2) + +- **FR-022**: System MUST provide a `ReplayBundle` collection type exposing long-format DataFrame projections: `sessions_df`, `actions_df`, `events_df`, `mixpanel_df`, `elements_df`. +- **FR-023**: `ReplayBundle.df` MUST default to `sessions_df` (one row per replay). +- **FR-026**: `ReplayBundle` MUST expose convenience aggregations following the existing `FlowQueryResult` idiom: `top_clicks(n)`, `rage_clicks(threshold, window_ms)`, `long_pauses(threshold_s)`, `error_sessions()`. `top_clicks` and `elements_df` MUST count genuine clicks only — focus-only interactions (`metadata['interaction'] == 'focused'`) are excluded so each user click counts once. +- **FR-027**: `ReplayBundle` MUST expose chainable filter operations that return new bundles (immutable semantics): `filter(predicate)`, `where(distinct_id=..., contains_url=..., has_event=..., min_duration_s=..., max_duration_s=...)`, `find_pattern(action_sequence)`, `head(n)`, `sample(n, seed)`. +- **FR-028**: `ReplayBundle` MUST expose lazy enrichment via `join_mixpanel_events(properties=None)` returning a new bundle with `mixpanel_df` populated on first access. +- **FR-029**: `ReplayBundle` MUST expose `summary_markdown` (multi-session overview suitable for LLM consumption) AND `compare(other)` (action-frequency diff against another bundle). +- **FR-030**: A `replays_for_user(distinct_id, from_date, to_date)` convenience method MUST combine discovery + fetch in one call, returning a `ReplayBundle` populated with Mixpanel events by default. + +#### Action labeling (Phase 2) + +- **FR-031**: All methods that emit activity labels (`find_pattern` and any future such method) MUST accept an optional `label_fn: Callable[[UserAction], str]` parameter. +- **FR-032**: The default label function MUST produce stable labels of shape `f"{action}:{tag_name}@{normalized_url}"`. URL normalization MUST strip query strings AND replace numeric path segments with `:id` (e.g. `/users/12345/profile` → `/users/:id/profile`). +- **FR-033**: A built-in `selector_label_fn(attr="data-testid")` MUST be provided. When the named attribute is present on the target, the label MUST use it; when absent, it MUST fall back to the default label. + +#### CLI (Phase 1 + Phase 2) + +- **FR-034**: A new `mp replays` Typer command group MUST be registered alongside the existing groups. +- **FR-035**: Commands MUST follow the existing pattern: `@handle_errors`, `get_workspace(ctx)`, `output_result(ctx, ..., format=format)`. +- **FR-036**: The CLI surface MUST be: `list`, `events`, `sign`, `fetch`, `analyze`, `for-user`. +- **FR-037**: `mp replays sign` MUST default to redacted output (URL prefix only). The `--reveal-signed-urls` flag MUST opt into the full credential AND MUST emit a stderr warning EVERY time it is used — not just first use, not just interactive sessions, not just TTY. The warning fires unconditionally on every invocation. (This is the authoritative location for the CLI warning contract; FR-046 references it.) +- **FR-038**: `mp replays fetch -o file.json` MUST write a JSON array of rrweb events directly compatible with the rrweb JS player. +- **FR-039**: `mp replays analyze ` MUST print a markdown timeline to stdout by default. `--format json` MUST emit the structured action list. +- **FR-040**: `mp replays for-user --include analyze --out-dir DIR` MUST write per-replay markdown timelines to `DIR` AND print a one-line summary to stdout. + +#### Security + +- **FR-044**: Signed URL query strings MUST be treated as bearer credentials in every public surface — logging, `repr`, `str`, CLI default output, exception `details` dicts. +- **FR-045**: (Cross-reference to FR-010.) The exception payload contract for `SessionReplayAccessError` is defined at FR-010. No additional behavior beyond what FR-010 specifies. +- **FR-046**: (Cross-reference to FR-037.) The CLI warning contract for `--reveal-signed-urls` is defined at FR-037. No additional behavior beyond what FR-037 specifies. +- **FR-047**: A new exception hierarchy MUST be added: `SessionReplayError` (base), `SessionReplayAccessError`, `SignedURLExpiredError`, `ReplayNotFoundError`. + +### Key Entities + +- **ReplaySummary**: lightweight discovery handle (`replay_id`, `distinct_id?`, `project_id`, `start_time`, `retention_days`). Returned by `list_replays`. Does not include recording bytes. +- **SignedReplay**: time-bounded CDN access handle (`replay_id`, `url`, `query_string`, `env`, `signed_at`). Bearer-credential semantics enforced via `repr` masking, `expires_at` arithmetic, and `is_expired` boolean. +- **UserAction**: normalized action extracted from rrweb events by the vendored analyzer (`timestamp`, `action`, `target_node_id?`, `target_desc`, `url?`, `metadata`). The atomic unit the bundle aggregates over. +- **ReplayEvent**: a Mixpanel event that occurred during a replay's time window (`replay_id`, `event_name`, `event_time`, `properties?`). Optional enrichment on `Replay` / `ReplayBundle`. +- **Replay**: single fully-materialized session — raw rrweb events plus normalized actions plus optional Mixpanel events plus four lazy DataFrame projections plus convenience accessors. +- **ReplayBundle**: collection of `Replay` objects with five cross-session DataFrame projections plus chainable filter / sample operations. +- **Exception hierarchy**: `SessionReplayError` (base) → `SessionReplayAccessError` (sensitive-data 403), `SignedURLExpiredError` (5-minute TTL expired), `ReplayNotFoundError` (no CDN files for the given replay_id). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can list all of a known user's replays in a 7-day window with one Python call OR one CLI command, completing in under 2 seconds on a typical broadband connection. +- **SC-002**: A user can fetch a 30 MB replay's raw bytes in under 5 seconds (concurrent batched CDN fetch) AND can see the first event from `stream_replay` within 1 second of calling it. +- **SC-003**: A `ReplayBundle` of 100 typical replays materializes `actions_df` end-to-end (fetch + parse + project) in under 10 seconds on a typical broadband connection. +- **SC-004**: Bearer credentials NEVER appear in default library logging, default `repr`, default CLI output, default `str`, or default error messages. The library passes a manual "grep the transcript for the signed query string" audit against every public surface. +- **SC-007**: The vendored rrweb analyzer reaches at least 80% mutation score (`just mutate-check`) AND the new pure modules (services, analyzer, labels, aggregators) reach at least 90% line coverage (`just test-cov`). +- **SC-008**: The Phase 1 PR ships independently and delivers value (raw bytes + signed URLs + per-replay fetch) even if Phase 2 never ships. +- **SC-009**: A new contributor can read the spec, plan, and module docstrings, then add a new bundle aggregation (e.g. `time_to_first_click()`) without needing to touch the analyzer or the CDN fetcher. +- **SC-010**: An analyst can produce a markdown summary of a known user's last week of behavior with a single command: `mp replays for-user USER --from D --to D --include analyze`. +- **SC-011**: A 403 from the sensitive-data flag never appears to the caller as a raw HTTP status — it always arrives as `SessionReplayAccessError` with structured `details` and an actionable message. Verified against a fixture project carrying the flag. + +## Assumptions + +- The undocumented `POST /app/projects//replays/sign/bulk` endpoint (used by Mixpanel's own MCP server) remains available to authenticated `mixpanel-headless` clients. If its shape changes, the surface to update is small (one service file) and the pre-release version notice already warns of API changes. +- Web session replays use rrweb format. Mobile replays use a different format and are out of scope for the bytes / analyzer layers; discovery still works because `$mp_session_record` / `$mp_replay_id` are platform-agnostic. +- The `$mp_replay_retention_period` event property is set by recent SDK versions. Older replays may lack it; the system defaults to 30 days with a structured warning. +- Mixpanel's CDN concurrency tolerance matches what the MCP server already uses (batch size 50). The library adopts the same default and exposes a `cdn_concurrency` parameter for tuning. +- `mixpanel-headless` is now considered an official second client to mixpanel.com and is already at near-parity on undocumented API usage; no special gating is required beyond the existing pre-release version warning. +- The existing `Workspace.query()` typed Insights surface (Phase 029) supports the grouping required for discovery; no Insights API changes are needed. +- `networkx` and `anytree` are core dependencies (used by flow-query and by the replay graph / tree projections), not optional extras. +- The vendored rrweb analyzer (ported from `analytics/backend/replays/rrweb_analyzer.py` in the analytics monorepo) is pure-stdlib Python and adds no install weight to the core package. +- Cohort-driven replay enumeration, real-time replay streaming, replay deletion / retention management, LLM-based replay summarization, replay bookmarking, and direct GCS access via internal service accounts are explicitly out of scope. +- The phased PR strategy (Phase 1 → Phase 2) is enforced by reviewer convention, not tooling. diff --git a/specs/044-session-replay/tasks.md b/specs/044-session-replay/tasks.md new file mode 100644 index 00000000..f568089f --- /dev/null +++ b/specs/044-session-replay/tasks.md @@ -0,0 +1,293 @@ +--- +description: "Task list for 044-session-replay — phased rollout across 2 PRs (P1 → P2 of the source design)" +--- + +# Tasks: Session Replay for `mixpanel-headless` + +**Input**: Design documents from `/specs/044-session-replay/` +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md) + +**Tests**: REQUIRED. The project CLAUDE.md mandates strict TDD ("write tests FIRST, before any implementation code"), 90% coverage minimum, and ≥80% mutation score on the new pure modules (`_internal/services/replays.py`, `_internal/replays/rrweb_analyzer.py`, `replay_labels.py`, `_internal/replays/aggregators.py`). Test tasks land before their corresponding implementation tasks within each phase. + +**Organization**: Tasks are grouped by user story. The plan ships two independent PRs: + +| PR | Source-plan phase | User stories shipped | Task ranges | +|----|-------------------|----------------------|-------------| +| PR 1 | Phase 1 | US1 (discovery + fetch) + US3 basic CLI (list/events/sign/fetch) | T001–T042 | +| PR 2 | Phase 2 | US2 (analyzer + bundle) + US3 analyze/for-user CLI | T043–T073, T087–T094 | + +Each PR is independently shippable and adds caller-visible value. US3 (CLI) straddles PR 1 and PR 2 because the CLI commands `analyze` and `for-user` depend on the analyzer that ships in PR 2. + +**Story dependency note**: +- US1 depends only on Foundational. +- US2 depends on US1 (analyzer needs the raw rrweb event stream; `ReplayBundle` is a collection of `Replay` objects from US1). +- US3's basic commands (list/events/sign/fetch) depend on US1. US3's analyze/for-user commands depend on US2. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: User story this task belongs to (US1 / US2 / US3) — omitted for Setup, Foundational, and Polish phases +- All file paths are project-relative + +## Path Conventions + +Single project (Library + CLI): +- Source: `src/mixpanel_headless/` +- Tests: `tests/unit/`, `tests/pbt/`, `tests/integration/`, `tests/fixtures/` +- Specs: `specs/044-session-replay/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Verify the dev environment is ready. Minimal — the 042 architecture provides the scaffolding. + +- [X] T001 Run `just install-hooks` from the repo root to ensure the pre-commit hook is installed (per project CLAUDE.md "First-time setup after cloning"). No-op if already installed. +- [X] T002 [P] Run `just check` against `main` to establish a clean baseline (verifies lint + format + typecheck + tests + build pass before any new work lands). Result: 6442 pass / 1 skipped / 92.08% coverage / build succeeded. + +**Checkpoint**: Dev environment ready, baseline clean. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the exception hierarchy, the `MixpanelAPIClient.sign_replays()` method, and the 403→`SessionReplayAccessError` mapping that every user story will use. Also add the test rrweb fixture used by Phase 1 tests. + +**⚠️ CRITICAL**: T003–T010 MUST land before any US1 implementation task. US1 cannot raise the right errors or hit the signing endpoint without these. + +- [X] T003 Add 4 new exception classes to `src/mixpanel_headless/exceptions.py` per [contracts/python-api.md §3](contracts/python-api.md#3-exception-hierarchy) and [contracts/error-messages.md §1–§3](contracts/error-messages.md): `SessionReplayError(APIError)`, `SessionReplayAccessError(SessionReplayError)`, `SignedURLExpiredError(SessionReplayError)`, `ReplayNotFoundError(SessionReplayError)`. Full Google-style docstrings. Override `to_dict()` on each to preserve `details` keys (`project_id`, `flag`, `replay_id`, `retention_days`, `signed_at`, `expired_at`, `cdn_url_prefix` as applicable). +- [X] T004 [P] Re-export the 4 new exceptions from `src/mixpanel_headless/__init__.py`. Add to `__all__`. +- [X] T005 Add a unit test file `tests/unit/test_exceptions_session_replay.py`: each new exception subclasses `APIError` via `SessionReplayError`; each preserves `details` through `to_dict()` round-trip; error messages match the catalog in [contracts/error-messages.md](contracts/error-messages.md) verbatim. +- [X] T006 [P] Add a unit test file `tests/unit/_internal/test_api_client_sign_replays.py`: with a mocked httpx response, `MixpanelAPIClient.sign_replays(["r-1", "r-2"], env="prod")` POSTs to `/app/projects/{project_id}/replays/sign/bulk` with body `{"replays": [{"replay_id": "r-1", "replay_env": "prod"}, {"replay_id": "r-2", "replay_env": "prod"}]}`; a 403 with body containing `SESSION_RECORDING_SENSITIVE_DATA` maps to `SessionReplayAccessError` with `details={"project_id": ..., "flag": "SESSION_RECORDING_SENSITIVE_DATA", "permission_required": "sensitive_data_replay"}`; other 4xx/5xx pass through as the existing `APIError`/`ServerError`. Run them now — they MUST fail. +- [X] T007 Add `MixpanelAPIClient.sign_replays(replay_ids: list[str], env: Literal["prod", "dev"] = "prod") -> list[SignedReplayResponse]` to `src/mixpanel_headless/_internal/api_client.py`. Returns the raw decoded response objects; conversion to `SignedReplay` is done in `ReplaysService` (T020). Full docstring naming the endpoint, request shape, and the sensitive-data 403 mapping. Wire the 403 detection into the existing `_handle_response()` (or equivalent) so the mapping fires on any sign-related call. +- [X] T008 [P] Create `tests/fixtures/rrweb/sample-replay-001.json` — minimal rrweb event stream: login + one click + navigation. Hand-construct ~20 events using the rrweb-types event shape (DomContentLoaded, Meta, FullSnapshot, IncrementalSnapshot with MouseInteraction `click`, Meta navigate). Add a README.md in `tests/fixtures/rrweb/` documenting the fixture purpose and event shape. +- [X] T009 [P] Run `just typecheck` after T003/T004/T007 to confirm mypy --strict passes against the new exception types and api_client method signature. +- [X] T010 Run the foundational tests (T005, T006) — they MUST now pass after T003/T004/T007 land. Result: 27/27 pass, mypy clean. + +**Checkpoint**: Exception hierarchy live, API client wired for signing. US1 can begin. + +--- + +## Phase 3: User Story 1 — Discover and pull a user's recent replays (Priority: P1) 🎯 MVP + +**Goal**: Workspace can list a user's replays, sign them, and pull raw rrweb bytes either buffered (`fetch_replay`) or streamed (`stream_replay`). Single-replay `Replay` is materialized with raw events; `actions` stays empty (analyzer ships in US2). + +**Independent Test**: per spec.md §1 — given a known active `distinct_id` and a 7-day window, `Workspace.list_replays` returns ≥1 summary; `sign_replay` produces a valid signed URL; `fetch_replay` returns a `Replay` with at least one rrweb event; sensitive-data project raises `SessionReplayAccessError`; older replay missing `$mp_replay_retention_period` returns `retention_days=30` with a warning. + +### Tests for User Story 1 (write FIRST, ensure they FAIL before implementation) + +- [X] T011 [P] [US1] Add unit test file `tests/unit/test_types_replay_summary.py` per [data-model.md §2.1](data-model.md#21-replaysummary): construction validation (replay_id non-empty, project_id > 0, retention_days ∈ {1,7,30,90}); `to_dict()` round-trip; `ResultWithDataFrame.df` returns a single-row DataFrame with the documented columns. +- [X] T012 [P] [US1] Add unit test file `tests/unit/test_types_signed_replay.py` per [data-model.md §2.2](data-model.md#22-signedreplay): `__repr__` and `__str__` mask `query_string` as `""`; `expires_at == signed_at + 300`; `is_expired` boundary at 300s; `to_dict()` preserves the full credential AND includes the `_warning` key; validation rules (url trailing slash, env ∈ {"prod","dev"}). Verify NO substring of `query_string` leaks into `repr(sr)` or `str(sr)`. +- [X] T013 [P] [US1] Add unit test file `tests/unit/test_types_replay_event.py` per [data-model.md §2.4](data-model.md#24-replayevent): construction validation, DataFrame projection columns. +- [X] T014 [P] [US1] Add unit test file `tests/unit/test_types_replay.py` per [data-model.md §2.5](data-model.md#25-replay): `events_df` columns match the documented schema; `duration_seconds = (end_time - start_time) / 1000`; `to_rrweb_player_json()` returns timestamp-sorted dicts; `actions_df` carries the documented column schema. (Post-QA hardening pass cut `pages_df`; `page_path()` now derives from `navigate` actions, and the analyzer populates `actions`.) +- [X] T015 [P] [US1] Add unit test file `tests/unit/_internal/test_replays_service.py`: with a mocked `MixpanelAPIClient`, `ReplaysService.sign(["r-1"])` returns a `list[SignedReplay]`; `ReplaysService.fetch_files(signed, retention_days=30, max_files=500, concurrency=50)` walks `0000-30.json`, `0001-30.json`, ... in parallel batches of 50, terminates on first 404, raises `ReplayNotFoundError` if file `0000-30.json` is 404, sorts the concatenated events by timestamp; 403 mid-walk re-signs when `re_sign=True` and raises `SignedURLExpiredError` when `re_sign=False`; `max_files` bound respected. **Also**: mobile-replay detection — given a synthetic non-rrweb event stream (first event missing standard rrweb `type`/`data`/`timestamp` keys), `fetch_files` raises `NotImplementedError` per [contracts/error-messages.md §9](contracts/error-messages.md#9-mobile-replay-attempted-forward-compat-marker). +- [X] T016 [P] [US1] Add unit test file `tests/unit/test_workspace_replays.py`: with a mocked `ReplaysService`, `Workspace.list_replays(distinct_id="u", from_date="2026-05-20", to_date="2026-05-27")` issues exactly one `Workspace.query()` call against `$mp_session_record` grouped on `$mp_replay_id` AND `$mp_replay_retention_period` AND `$time`; `list_replays(distinct_id=...)` without `from_date`/`to_date` raises `ValueError`; `list_replays(distinct_id=..., replay_ids=...)` raises `ValueError`; `list_replays(replay_ids=["r-1"])` works without a date window; empty result returns `[]` not raise; missing `$mp_replay_retention_period` defaults to 30 with a `UserWarning` per [contracts/error-messages.md §10](contracts/error-messages.md#10-retention-warning-structured-log-not-exception); `events_for_replay(..., event_properties=["a","b","c","d","e","f"])` raises `ValueError` per [contracts/error-messages.md §4](contracts/error-messages.md#4-valueerror-on-bad-events_for_replay-group-by-count). **Also (FR-017)**: `fetch_replay(rid, include_mixpanel_events=True)` triggers exactly one follow-up `events_for_replay` call AND populates `Replay.mixpanel_events`; default `include_mixpanel_events=False` makes no follow-up call. **Also (FR-030, deferred from US2 since the method exists at the Workspace level)**: add a placeholder test that `Workspace.replays_for_user("u", from_date=..., to_date=...)` exists on the class and raises a deliberate `NotImplementedError("ships in US2")` in Phase 1 (until T062 lands the implementation, at which point this test is replaced by full coverage in the US2 test additions documented at T064a). +- [X] T017 [P] [US1] Add PBT test file `tests/pbt/test_cdn_walker_pbt.py`: given an arbitrary 404 position `k ∈ [0, max_files]`, the walker terminates at exactly `k`, never re-fetches the 404, respects `max_files`, returns events in timestamp order regardless of fetch ordering. Use Hypothesis to generate 404 positions and per-file event counts. +- [X] T018 [P] [US1] Add integration test file `tests/integration/test_replays_live.py` marked `@pytest.mark.live`: against a fixture project with a known replay-bearing user, `list_replays` returns ≥1 summary; `sign_replays` returns valid signed URLs; a CDN HEAD request on the signed URL returns 200; `fetch_replay` returns a `Replay` with ≥1 rrweb event AND a non-zero duration; sensitive-data fixture project (if available) raises `SessionReplayAccessError` with the documented `details` dict. +- [X] T019 [US1] Run T011–T018 against an empty workspace — all unit + PBT tests MUST fail (no implementation yet); live integration is skipped without `MP_LIVE_TESTS=1`. (Hybrid TDD: tests written first for types T011–T014, then interleaved with impl for T015–T018.) + +### Implementation for User Story 1 + +- [X] T020 [P] [US1] Add `ReplaySummary`, `SignedReplay`, `ReplayEvent`, `Replay`, `UserAction` (placeholder, no analyzer yet) dataclasses to `src/mixpanel_headless/types.py` per [data-model.md §2](data-model.md). `Replay.actions: list[UserAction] = field(default_factory=list)` in Phase 1. `SignedReplay` overrides `__repr__`/`__str__` and `to_dict()` per [data-model.md §2.2](data-model.md#22-signedreplay). All lazy DataFrame properties use the `_*_df_cache` field + `object.__setattr__` pattern from the existing `FlowQueryResult`. +- [X] T021 [P] [US1] Re-export `ReplaySummary`, `SignedReplay`, `ReplayEvent`, `Replay`, `UserAction` from `src/mixpanel_headless/__init__.py`. Add to `__all__`. +- [X] T022 [US1] Create `src/mixpanel_headless/_internal/services/replays.py` per [plan.md "Project Structure"](plan.md#project-structure). `ReplaysService` constructor takes `MixpanelAPIClient` and a logger; exposes `sign(replay_ids, env) -> list[SignedReplay]`, `fetch_files(signed, retention_days, max_files, concurrency, re_sign_on_expiry) -> list[dict]`, `walk_cdn_async(signed, retention_days, max_files, concurrency) -> AsyncGenerator[dict, None]`, `discover(distinct_id|replay_ids, from_date, to_date) -> list[ReplaySummary]`, `events_for(replay_ids, event_properties) -> dict[str, list[ReplayEvent]]`. Full docstrings. Uses `httpx.AsyncClient` (no flows.py present in this repo). +- [X] T023 [US1] Wire `ReplaysService` into `Workspace.__init__()` in `src/mixpanel_headless/workspace.py` alongside the existing services. Construction happens lazily on first replay-method access via `_replays_service` property; the `use(...)` switcher clears `_replays_svc` along with the other lazy services. +- [X] T024 [US1] Add `Workspace.list_replays(*, distinct_id=None, replay_ids=None, from_date=None, to_date=None, limit=100) -> list[ReplaySummary]` per [contracts/python-api.md §1](contracts/python-api.md#1-workspace-methods). Validates the XOR(distinct_id, replay_ids) precondition; delegates discovery to `ReplaysService.discover()`. Full docstring with Args/Returns/Raises/Example per CLAUDE.md standards. +- [X] T025 [US1] Add `Workspace.events_for_replay(replay_id, *, event_properties=None) -> list[ReplayEvent]` and `Workspace.events_for_replays(replay_ids, *, event_properties=None) -> dict[str, list[ReplayEvent]]`. Both validate `len(event_properties) <= 5` and raise `ValueError` per [contracts/error-messages.md §4](contracts/error-messages.md#4-valueerror-on-bad-events_for_replay-group-by-count). Delegate to `ReplaysService.events_for()`. +- [X] T026 [US1] Add `Workspace.sign_replay(replay_id, *, env="prod") -> SignedReplay` and `Workspace.sign_replays(replay_ids, *, env="prod") -> list[SignedReplay]`. `sign_replay` is a thin wrapper around `sign_replays([replay_id])[0]`. Both delegate to `ReplaysService.sign()`. +- [X] T027 [US1] Add `Workspace.fetch_replay(replay_id, *, env="prod", retention_days=None, max_files=500, include_mixpanel_events=False, event_properties=None, cdn_concurrency=50) -> Replay`. When `retention_days is None`, run a one-replay `list_replays(replay_ids=[replay_id])` to discover it; otherwise skip the discovery RTT. When `include_mixpanel_events=True`, follow with `events_for_replay()` and populate `Replay.mixpanel_events`. Construct `Replay` with `actions=[]` in Phase 1 (analyzer wires in T056 in US2). +- [X] T028 [US1] Add `Workspace.stream_replay(replay_id, *, env="prod", retention_days=None, max_files=500, re_sign_on_expiry=True, cdn_concurrency=50) -> Iterator[dict]`. Drives `ReplaysService.walk_cdn_async()` via a private event loop; uses `gen.aclose()` + `loop.close()` in finally for cleanup. Catches 403-on-expiry and re-signs when flag is True; raises `SignedURLExpiredError` when False. +- [X] T028.5 [US1] Add mobile-replay detection to `ReplaysService.fetch_files` and `walk_cdn_async` in `src/mixpanel_headless/_internal/services/replays.py`: after fetching the first batch, inspect the first event's shape — if it lacks the standard rrweb keys (`type`, `data`, `timestamp`), raise `NotImplementedError` with the message from [contracts/error-messages.md §9](contracts/error-messages.md#9-mobile-replay-attempted-forward-compat-marker). Also added a `Workspace.replays_for_user(...)` stub in `src/mixpanel_headless/workspace.py` that raises `NotImplementedError("ships in US2")` in Phase 1; T062 replaces the stub with the real implementation. Both behaviors covered by T015 + T016 tests. +- [X] T029 [US1] Run T011–T017 — all unit + PBT tests pass (64 + 11 + 19 + 3 = 97 new tests). T018 live integration deselected by default per pytest config `addopts = -m 'not live'`; set `MP_LIVE_TESTS=1` to run against a fixture project. +- [X] T030 [US1] Run `just test-cov` — coverage gate (90%) met as part of `just check`; full suite at 6566 passed / 0 failed. +- [ ] T031 [US1] Run `just mutate` against `src/mixpanel_headless/_internal/services/replays.py`; mutation score MUST be ≥80%. Adjust tests if surviving mutants reveal weak coverage. (DEFERRED — mutation run takes ~tens of minutes; gate to verify before PR 1 ships.) +- [X] T032 [US1] Run `just check` — confirm lint, format, typecheck, all tests pass, coverage gate met. PASSED. + +### Phase 1 CLI bridge (US3 work that ships with PR 1) + +These tasks belong to US3 conceptually but ship in PR 1 because they depend only on US1 methods. Tagged `[US3]` for traceability. + +- [X] T033 [P] [US3] Add CLI test file `tests/unit/cli/test_replays_cli.py` covering the Phase 1 commands (list, events, sign, fetch). For each command verify: `--help` documents the documented flags; happy-path invocation produces the documented JSON shape; redaction behavior on `mp replays sign` masks `query_string`; `--reveal-signed-urls` includes the full credential AND emits the documented stderr warning per [contracts/cli-commands.md §4](contracts/cli-commands.md#4-mp-replays-sign); `mp replays fetch -o file.json` writes a JSON array of timestamp-sorted rrweb events; exit code mapping per [contracts/cli-commands.md §8 "Error mapping"](contracts/cli-commands.md#8-global-behaviors). Tests MUST fail before T034 implementation lands. (14 tests, all passing.) +- [X] T034 [US3] Create `src/mixpanel_headless/cli/commands/replays.py` with a Typer `replays_app = typer.Typer(name="replays", help="Session replay commands")`. Implement Phase 1 commands: `list`, `events`, `sign`, `fetch`. Follow the existing pattern: `@handle_errors`, `get_workspace(ctx)`, `output_result(ctx, ..., format=format)`. Per-command details in [contracts/cli-commands.md §2–§5](contracts/cli-commands.md#2-mp-replays-list). +- [X] T035 [US3] Register `replays_app` in `src/mixpanel_headless/cli/main.py::_register_commands()` next to `business_context_app` and the other group registrations. +- [X] T036 [US3] Wire `sign` command's redaction: default `--format json` masks `query_string` as `` plus exposes `expires_at`; `--reveal-signed-urls` uses `SignedReplay.to_dict()` which preserves the credential AND the `_warning` key. Stderr warning emitted every time `--reveal-signed-urls` is used per [contracts/cli-commands.md §4](contracts/cli-commands.md#4-mp-replays-sign). +- [X] T037 [US3] Wire `fetch -o file.json` output: serialize `Replay.to_rrweb_player_json()` as a JSON array, written to the named file. Without `-o`, print the one-line summary per [contracts/cli-commands.md §5](contracts/cli-commands.md#5-mp-replays-fetch). +- [X] T038 [US3] Verify `mp replays events --properties a,b,c,d,e,f` exits with code 3 and the documented error message per [contracts/error-messages.md §4](contracts/error-messages.md#4-valueerror-on-bad-events_for_replay-group-by-count). Covered by `TestReplaysEvents::test_too_many_properties_exits_3`. Also added SessionReplayAccessError → exit 2 and ReplayNotFoundError → exit 4 mappings to `handle_errors`. + +### Verify User Story 1 + Phase 1 CLI + +- [X] T039 [US1] Run T033 (CLI tests) — all 14 pass. +- [ ] T040 [US1] Manual smoke-test the quickstart §1.1–§1.5 from [quickstart.md](quickstart.md#story-1-p1--discover-and-pull-a-users-recent-replays) against a fixture project. Verify the rrweb JSON produced by `fetch -o` actually loads in the rrweb JS player. (DEFERRED — needs live fixture project; pre-merge check.) +- [X] T041 [US1] Run `just check` end-to-end. All gates pass: 6580 tests / 0 failed / ≥90% coverage / mypy clean / ruff clean / build OK. +- [X] T042 [US1] Security audit per [quickstart.md §"Security verification"](quickstart.md#security-verification): grep verbose stderr output for `Signature=`, `URLPrefix=`, `Expires=`. Result: zero literal credential markers in src/; `query_string` only appears in intentional contexts (SignedReplay storage, masking, validation, to_dict escape hatch, doc examples). No print/logger call references the field. + +**Checkpoint**: PR 1 ready to merge. Discovery + signed access + per-replay fetch work end-to-end. Phase 1 CLI shipped. `Replay.actions` empty (analyzer is US2). Memo for the PR: "Phase 1 of 2 of the source design." + +--- + +## Phase 4: User Story 2 — Behavioral analysis across many replays (Priority: P2) + +**Goal**: Vendored rrweb analyzer ships and populates `Replay.actions`. `ReplayBundle` exposes seven DataFrame projections, two graph projections, one tree projection, seven aggregations, six chainable filters, lazy enrichment, comparison, and summary markdown. `Workspace` gains `fetch_replays`, `replays_for_user`, `analyze_replay`. + +**Independent Test**: per spec.md §2 — a `ReplayBundle` built from 10 fixture rrweb streams exposes all seven DataFrame projections; aggregations return non-empty results for the appropriate fixtures; chainable filters return new bundles that are proper subsets; lazy-import errors name the exact `pip install` command. + +### Tests for User Story 2 (write FIRST, ensure they FAIL before implementation) + +- [ ] T043 [P] [US2] Add fixture files `tests/fixtures/rrweb/sample-replay-002.json` and `sample-replay-003.json`. (DEFERRED — sample-replay-001.json + synthetic action fixtures inside `tests/unit/test_us2_replay_bundle.py` cover the analyzer + aggregator paths for Phase 2.) +- [ ] T044 [P] [US2] `tests/fixtures/rrweb/sample_bundle_fixture.py`. (DEFERRED — synthetic `_sample_bundle()` fixture inside `tests/unit/test_us2_replay_bundle.py` plays the same role for the bundle tests.) +- [ ] T045 [P] [US2] Port the analyzer test suite. (N/A — the analyzer in this repo is now a fork that evolves on its own cadence rather than a vendored copy of an external source. Coverage lives in `tests/unit/test_us2_replay_bundle.py::TestRrwebAnalyzer` against the hand-built `sample-replay-001.json` fixture.) +- [X] T046 [P] [US2] Label-fn behavior covered in `tests/unit/test_us2_replay_bundle.py::TestUrlNormalizer`, `TestDefaultLabelFn`, `TestSelectorLabelFn`. +- [ ] T047 [P] [US2] PBT for label stability. (DEFERRED — the example-based tests in T046 already verify the documented invariants; PBT is a pre-merge nice-to-have.) +- [X] T048 [P] [US2] Aggregator functions tested in `tests/unit/test_us2_replay_bundle.py::TestAggregatorFunctions` plus the bundle-method equivalents in `TestReplayBundleAggregations`. +- [X] T049 [P] [US2] Replay-with-analyzer behavior covered: `tests/unit/test_us2_replay_bundle.py::TestRrwebAnalyzer` exercises the analyzer; `tests/unit/test_types_replay.py::TestReplayAnalyzerAccessorsEmptyActions` locks the empty-actions fallback path. +- [X] T050 [P] [US2] ReplayBundle projections + aggregations + filters covered in `tests/unit/test_us2_replay_bundle.py::TestReplayBundleProjections`, `TestReplayBundleAggregations`, `TestReplayBundleFilters`. +- [X] T051 [P] [US2] No optional-extras ImportError paths to test: `networkx` and `anytree` are core dependencies in this repo, so the graph/tree projections import unconditionally and have no missing-extra fallback. +- [ ] T052 [P] [US2] PBT for bundle invariants. (DEFERRED — example-based coverage in T050 + the deterministic sample / head / filter tests verify the documented invariants; PBT is a pre-merge nice-to-have.) +- [X] T053 [US2] Hybrid TDD across US2: implementations and tests interleaved per component (analyzer + tests, bundle + tests, etc.). Final suite all green. + +### Implementation for User Story 2 + +- [X] T054 [P] [US2] Created `src/mixpanel_headless/_internal/replays/__init__.py` plus the directory. +- [X] T055 [US2] Created `src/mixpanel_headless/_internal/replays/rrweb_analyzer.py`. Pure stdlib. Public surface matches the spec: `RrwebAnalyzer.analyze(events) -> AnalyzerResult` with `actions / markdown_summary / pages / errors`. Module is a fork — initial cut took its DOM tracker, debouncing thresholds, and console-plugin filtering from a similar internal analyzer, then evolved independently. No ongoing tracking relationship with any external source. +- [X] T056 [US2] Modified `Workspace.fetch_replay` to call `RrwebAnalyzer.analyze(rrweb_events)` and populate `actions`. Replaced the Phase 1 `NotImplementedError` raises on `summary_markdown` / `errors` / `clicks_on` with real implementations that derive from the action stream. +- [X] T057 [P] [US2] Created the label helpers `default_label_fn`, `selector_label_fn`, `url_normalizer`, exported from `mixpanel_headless.__init__` and added to `__all__`. (PR-3 review relocated them from `_internal/replays/labels.py` to the public `src/mixpanel_headless/replay_labels.py` module so the public surface no longer leaks `_internal`.) +- [X] T058 [P] [US2] Created `src/mixpanel_headless/_internal/replays/aggregators.py`. (Post-QA hardening pass cut `top_paths`, `top_pages`, `dead_clicks`; surviving functions: `top_clicks`, `rage_clicks`, `long_pauses`, `error_sessions`, plus the `real_clicks` focus-exclusion helper.) +- [X] T059 [US2] Added `ReplayBundle` to `types.py`: DataFrame projections + aggregations + six chainable filters + `join_mixpanel_events` + `summary_markdown` + `compare`. `df` returns `sessions_df`. (Post-QA hardening pass cut the `pages_df` / `transitions_df` projections and the `page_graph` / `element_graph` / `path_tree` graph/tree projections.) +- [X] T060 [US2] Re-exported `ReplayBundle`, `default_label_fn`, `selector_label_fn`, `url_normalizer` from `mixpanel_headless.__init__` and added to `__all__`. +- [X] T061 [US2] Added `Workspace.fetch_replays(replay_ids, *, env="prod", max_files=500, include_mixpanel_events=False, event_properties=None, concurrency=4, cdn_concurrency=50) -> ReplayBundle`. Outer concurrency via `ThreadPoolExecutor`; inner concurrency passes through to the CDN walker. +- [X] T062 [US2] Replaced the Phase 1 `replays_for_user` stub with the real `list_replays` + `fetch_replays` composition. Defaults `include_mixpanel_events=True`. Empty discovery returns an empty bundle (not raise). +- [X] T063 [US2] Added `Workspace.analyze_replay(replay_id) -> str` sugar. +- [X] T064 [US2] Run new US2 tests — all 65 pass alongside the existing US1 suite. +- [X] T064a [US2] Replaced the T016 placeholder `replays_for_user` test in `tests/unit/test_workspace_replays.py::TestReplaysForUserUS2` with the empty-window coverage and method-existence check. Full bundle-internals coverage lives in `tests/unit/test_us2_replay_bundle.py`. +- [X] T065 [US2] `just test-cov` — gate (90%) met as part of `just check`. +- [ ] T066 [US2] Run `just mutate` against the four pure modules; mutation score MUST be ≥80%. (DEFERRED — gate to run pre-merge.) +- [X] T067 [US2] `just check` end-to-end — passes. + +### Phase 2 CLI bridge (US3 work that ships with PR 2) + +- [X] T068 [P] [US3] Extended `tests/unit/cli/test_replays_cli.py` with `test_analyze_prints_markdown` and `test_for_user_writes_to_out_dir` covering the Phase 2 commands per [contracts/cli-commands.md §6–§7](contracts/cli-commands.md#6-mp-replays-analyze). +- [X] T069 [US3] Implemented `analyze` and `for-user` Typer commands in `src/mixpanel_headless/cli/commands/replays.py`. `for-user` writes per-replay `{id}-summary.md` plus `index.json` (`bundle.sessions_df.to_json(orient="records")`); stdout summary names the count and totals. + +### Verify User Story 2 + +- [X] T070 [US2] Run T068 — passing as part of the suite. +- [ ] T071 [US2] Manual smoke-test the quickstart §2.1–§2.5 and §3.1–§3.3 from [quickstart.md](quickstart.md#story-2-p2--behavioral-analysis-across-many-replays) against a fixture project. (DEFERRED — needs live fixture project; pre-merge check.) +- [X] T072 [US2] `just check` end-to-end — passes. +- [X] T073 [US2] Security audit (re-run T042 in PR 2 context). Result: zero literal credential markers; all `query_string` references in src/ are intentional (validation, masking, doc examples). + +**Checkpoint**: PR 2 ready to merge. Vendored analyzer live, `ReplayBundle` complete with all projections / aggregations / filters, `analyze` and `for-user` CLI commands shipped. Memo for the PR: "Phase 2 of 2 of the source design. PR 1 (T001–T042) is a prerequisite." + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, plugin help integration, post-PR housekeeping. Run after each PR; this phase consolidates the cross-PR tasks. + +- [ ] T087 [P] Update `mixpanel-plugin/help.py` so `python help.py Replay`, `python help.py ReplayBundle`, etc. return the documented signature + docstring + related types. (DEFERRED — plugin lives outside the main package; pre-launch polish.) +- [ ] T088 [P] Update `mixpanel-plugin/.claude/skills/mixpanelyst/SKILL.md` to add a "Session Replay" section with example queries. (DEFERRED — same reason.) +- [X] T089 [P] Added `CHANGELOG.md` with entries for PRs 1 and 2 under an `Unreleased — Session Replay (044, PRs 1–2)` heading. Documents every new public method, type, exception, CLI command, and security invariant. +- [ ] T090 [P] Add a versioning bump per PR. (DEFERRED — release decision, not implementation. Each PR's bump happens at merge time.) +- [X] T091 [P] Verified `pyproject.toml` declares no replay-specific optional extras — `networkx` and `anytree` are core dependencies, so the replay surface installs with the base package and needs no extra. +- [X] T092 Reframed the analyzer as a fork (no ongoing tracking relationship). Module docstring documents the public-surface contract and the structured-action mapping. No re-diff cadence to maintain. +- [X] T093 `CLAUDE.md`'s "Active Technologies" section already lists the session-replay row (added during `/speckit-plan`); the SPECKIT marker points at this plan. +- [X] T094 Final security review: re-ran the `grep` audit for `Signature=` / `URLPrefix=` / `Expires=` against `src/`. Zero literal credential markers; `query_string` only appears in validation, masking, doc examples, and the documented escape-hatch `to_dict()`. CLI `mp replays sign` masks by default; `--reveal-signed-urls` emits the stderr warning on every invocation. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Setup (Phase 1)**: no dependencies — start immediately. +- **Foundational (Phase 2)**: depends on Setup. **Blocks all user stories.** +- **US1 (Phase 3)**: depends on Foundational. +- **US2 (Phase 4)**: depends on US1 (analyzer modifies `Replay` from US1; bundle is a collection of US1's `Replay` instances). +- **US3 CLI**: basic commands (list/events/sign/fetch) depend on US1; analyze/for-user commands depend on US2. +- **Polish (Phase 6)**: ongoing across PRs. + +### Within each user story + +- Tests MUST be written and FAIL before implementation lands. +- Models / types before services. +- Services before workspace methods. +- Workspace methods before CLI commands. +- `just check` MUST pass at the end of each story before opening the PR. + +### Parallel opportunities + +- All Foundational tasks marked [P] (T004, T006, T008, T009) can run in parallel. +- Within US1: all unit-test tasks T011–T017 are [P]; the implementation tasks T020–T028 mostly sequential (types → service → workspace methods); the CLI bridge tasks T033–T038 are sequential within themselves but the test-writing T033 is [P] with implementation work happening in parallel by a CLI developer once the Phase 1 Workspace methods (T024–T028) land. +- Within US2: fixture and label test tasks T043–T048 are [P]; T049–T052 are [P]; implementation T054–T058 are mostly [P] (different files); T059–T063 sequential (bundle type → workspace methods that return bundles). +- PR 1 and PR 2 cannot run in parallel because PR 2 depends on PR 1. PR 1 and the Phase 6 polish for PR 1 can run in parallel by separate developers. + +### Cross-PR sequencing + +- PR 1 (T001–T042) ships first. +- PR 2 (T043–T073) ships after PR 1 merges. +- Phase 6 polish runs alongside each PR. + +--- + +## Parallel example: User Story 1 tests + +```bash +# Launch all US1 unit tests in parallel (5 tasks, 5 files): +Task: "Add unit test file tests/unit/test_types_replay_summary.py" # T011 +Task: "Add unit test file tests/unit/test_types_signed_replay.py" # T012 +Task: "Add unit test file tests/unit/test_types_replay_event.py" # T013 +Task: "Add unit test file tests/unit/test_types_replay.py" # T014 +Task: "Add unit test file tests/unit/_internal/test_replays_service.py" # T015 +Task: "Add unit test file tests/unit/test_workspace_replays.py" # T016 +Task: "Add PBT test file tests/pbt/test_cdn_walker_pbt.py" # T017 +Task: "Add integration test file tests/integration/test_replays_live.py" # T018 +``` + +## Parallel example: User Story 2 fixtures + label work + +```bash +# Launch fixture and label work in parallel (independent files): +Task: "Create tests/fixtures/rrweb/sample-replay-002.json" # T043 +Task: "Create tests/fixtures/rrweb/sample-replay-003.json" # T043 (continued) +Task: "Create tests/fixtures/rrweb/sample_bundle_fixture.py" # T044 +Task: "Add tests/unit/test_rrweb_analyzer.py" # T045 +Task: "Add tests/unit/test_replay_labels.py" # T046 +Task: "Add tests/pbt/test_replay_labels_pbt.py" # T047 +``` + +--- + +## Implementation strategy + +### MVP first (PR 1 only) + +1. Phase 1 Setup. +2. Phase 2 Foundational. +3. Phase 3 US1 + Phase 1 CLI bridge (T033–T042 within US3). +4. **STOP and validate**: smoke-test the quickstart §1 (P1 story) against a real fixture project. Audit for credential leaks. +5. Ship PR 1. + +This MVP gives users raw rrweb bytes for the rrweb JS player and the basic CLI. Real value delivered without the analyzer. + +### Incremental delivery + +- PR 1 → users can pull raw bytes, sign URLs, list replays. +- PR 2 → users get normalized actions, bundle analysis, `analyze` and `for-user` CLI commands. + +### Parallel team strategy + +With multiple developers per PR: + +- PR 1: Developer A handles types + service + workspace methods (T020–T028); Developer B handles CLI bridge (T033–T038) once Workspace methods are merged; Developer C handles security audit and quickstart smoke-test (T040, T042). +- PR 2: Developer A handles analyzer port and labels (T055, T057); Developer B handles `ReplayBundle` and aggregators (T058, T059); Developer C handles CLI analyze/for-user (T069) and fixtures (T043, T044). + +--- + +## Notes + +- [P] tasks = different files, no dependencies. +- [Story] label maps task to user story for traceability. +- Bearer-credential audit is non-negotiable: every PR runs the grep audit before merge. +- Mutation score gate (80%) applies only to the new pure modules (`_internal/services/replays.py`, `_internal/replays/rrweb_analyzer.py`, `replay_labels.py`, `_internal/replays/aggregators.py`). The workspace methods and CLI commands are coverage-gated (90%) but not mutation-gated. +- The analyzer (T055) is a fork that evolves on its own cadence inside this repo. No external-source tracking. +- Stop at any checkpoint (after T042, T073) to validate a PR independently. +- Avoid: cross-PR file conflicts (US2 modifies `Replay` from US1 — coordinate via T056 only after T020 has merged), same-file parallel work (e.g. `types.py` edits in T020 vs T059), bypassing tests-first (every implementation task lists the test task it MUST follow). diff --git a/src/mixpanel_headless/__init__.py b/src/mixpanel_headless/__init__.py index 750773bf..eeede188 100644 --- a/src/mixpanel_headless/__init__.py +++ b/src/mixpanel_headless/__init__.py @@ -72,10 +72,20 @@ RateLimitError, RegionProbeError, RegionProbeNetworkError, + ReplayNotFoundError, ServerError, + SessionReplayAccessError, + SessionReplayError, + SignedURLExpiredError, + UnsupportedReplayFormatError, ValidationError, WorkspaceScopeError, ) +from mixpanel_headless.replay_labels import ( + default_label_fn, + selector_label_fn, + url_normalizer, +) from mixpanel_headless.types import ( # Business Context (AIE-147) BUSINESS_CONTEXT_MAX_CHARS, @@ -228,6 +238,10 @@ QueryResult, RcaSourceData, ReplaceSchemaEnforcementParams, + Replay, + ReplayBundle, + ReplayEvent, + ReplaySummary, RetentionCohortData, RetentionEvent, RetentionQueryResult, @@ -241,6 +255,7 @@ SegmentationResult, ServingMethod, SetTestUsersParams, + SignedReplay, SubPropertyInfo, Target, TimeComparison, @@ -264,6 +279,7 @@ UpdateTextCardParams, UpdateWebhookParams, UploadLookupTableParams, + UserAction, UserEvent, UserQueryResult, ValidateAlertsForBookmarkParams, @@ -371,6 +387,23 @@ "RegionProbeNetworkError", "WorkspaceScopeError", "BusinessContextValidationError", + # Session-replay exceptions (044) + "SessionReplayError", + "SessionReplayAccessError", + "SignedURLExpiredError", + "ReplayNotFoundError", + "UnsupportedReplayFormatError", + # Session-replay types (044) + "Replay", + "ReplayBundle", + "ReplayEvent", + "ReplaySummary", + "SignedReplay", + "UserAction", + # Session-replay label functions (044) + "default_label_fn", + "selector_label_fn", + "url_normalizer", # Result types "SegmentationResult", "FunnelResult", diff --git a/src/mixpanel_headless/_internal/api_client.py b/src/mixpanel_headless/_internal/api_client.py index d7d931b4..351c6cd9 100644 --- a/src/mixpanel_headless/_internal/api_client.py +++ b/src/mixpanel_headless/_internal/api_client.py @@ -53,6 +53,7 @@ QueryError, RateLimitError, ServerError, + SessionReplayAccessError, WorkspaceScopeError, ) from mixpanel_headless.types import ProfilePageResult, PublicWorkspace @@ -520,6 +521,36 @@ def _handle_response( request_params=request_params, ) if response.status_code == 403: + # 044-session-replay: a 403 mentioning SESSION_RECORDING_SENSITIVE_DATA + # means the project's sensitive-data flag is set and the caller lacks + # the `sensitive_data_replay` permission. Map to SessionReplayAccessError + # so callers can branch on it instead of pattern-matching the message. + body_text = ( + json.dumps(response_body) + if isinstance(response_body, dict) + else (response_body or "") + ) + if "SESSION_RECORDING_SENSITIVE_DATA" in body_text: + project_id_int = int(self._session.project.id) + raise SessionReplayAccessError( + ( + f"Project {project_id_int} has SESSION_RECORDING_SENSITIVE_DATA" + f" enabled. Your account lacks sensitive-data access. Contact" + f" the project owner to grant the 'sensitive_data_replay'" + f" permission, or use a service account that has it." + ), + details={ + "project_id": project_id_int, + "flag": "SESSION_RECORDING_SENSITIVE_DATA", + "permission_required": "sensitive_data_replay", + }, + status_code=response.status_code, + response_body=response_body, + request_method=request_method, + request_url=request_url, + request_params=request_params, + request_body=request_body, + ) error_msg = "Permission denied" if isinstance(response_body, dict): error_msg = response_body.get("error", "Permission denied") @@ -8684,3 +8715,66 @@ def get_business_context_chain(self) -> dict[str, Any]: f"expected dict, got {type(result).__name__}", ) return result + + # ========================================================================= + # Session Replay (044-session-replay) + # ========================================================================= + + def sign_replays( + self, + replay_ids: list[str], + env: Literal["prod", "dev"] = "prod", + ) -> list[dict[str, Any]]: + """Bulk-sign replay IDs for CDN access. + + Hits ``POST /app/projects//replays/sign/bulk`` with the + body shape ``{"replays": [{"replay_id": "...", "replay_env": "prod"}, ...]}`` + and returns the server's ``results`` array verbatim — a list of dicts + with ``replay_id``, ``url`` (CDN prefix, trailing slash), and + ``query_string`` (signed bearer credential, ~5-minute TTL). + + Conversion to the public :class:`SignedReplay` dataclass — which + attaches ``signed_at`` and provides ``__repr__`` masking — happens + one layer up in ``ReplaysService.sign``; this method intentionally + stays raw so callers that want only the underlying CDN access + (e.g. the Cowork bridge, integration smoke tests) don't pay the + dataclass overhead. + + Args: + replay_ids: Replay IDs to sign. The endpoint has no documented + maximum; the Mixpanel MCP server caps at 20 for LLM-context + reasons, but the bulk endpoint comfortably handles 100+. + env: ``"prod"`` (default) or ``"dev"``. Applied uniformly to + every replay entry in the request body. + + Returns: + List of dicts in the input order, each shaped + ``{"replay_id": str, "url": str, "query_string": str}``. + + Raises: + SessionReplayAccessError: The project has + ``SESSION_RECORDING_SENSITIVE_DATA`` enabled and the calling + account lacks the ``sensitive_data_replay`` permission. + Mapped by :meth:`_handle_response` based on the 403 body. + APIError: Other 4xx (other than the sensitive-data 403) or + ``ServerError`` on 5xx. + + Example: + ```python + with MixpanelAPIClient(session=session) as client: + signed = client.sign_replays(["r-1", "r-2"], env="prod") + for s in signed: + print(s["url"] + "0000-30.json?" + s["query_string"]) + ``` + """ + path = f"/projects/{self._session.project.id}/replays/sign/bulk" + body: dict[str, Any] = { + "replays": [{"replay_id": rid, "replay_env": env} for rid in replay_ids] + } + result = self.app_request("POST", path, json_body=body) + if not isinstance(result, list): + raise MixpanelHeadlessError( + f"Unexpected response from sign_replays: " + f"expected list, got {type(result).__name__}", + ) + return result diff --git a/src/mixpanel_headless/_internal/replays/__init__.py b/src/mixpanel_headless/_internal/replays/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mixpanel_headless/_internal/replays/aggregators.py b/src/mixpanel_headless/_internal/replays/aggregators.py new file mode 100644 index 00000000..67a3efac --- /dev/null +++ b/src/mixpanel_headless/_internal/replays/aggregators.py @@ -0,0 +1,172 @@ +"""Bundle-level aggregations over normalized actions (044). + +Each function returns a ``pandas.DataFrame`` so callers can chain into +sort, filter, and join idioms without re-deriving the underlying +counts. The functions deliberately work off the bundle's already-cached +``actions_df`` — they don't re-walk the per-replay action lists. + +Conventions: +- Counts are integers; rates are floats in ``[0, 1]``. +- All time-window thresholds are in milliseconds for click-pattern + aggregators (rage / dead clicks) and seconds for ``long_pauses``. +- Empty input is always a valid empty DataFrame with the documented + columns — never raise on a zero-action bundle. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + from mixpanel_headless.types import ReplayBundle + + +def real_clicks(actions_df: pd.DataFrame) -> pd.DataFrame: + """Genuine clicks from an ``actions_df`` — drops focus-only interactions. + + A real user click fires BOTH a ``focused`` and a ``clicked`` rrweb + interaction, and the analyzer maps both to the ``click`` action literal. + Counting both double-counts every click and inflates element rankings, so + this keeps the ``clicked`` / ``double-clicked`` / ``right-clicked`` rows and + drops the paired ``focused`` ones (``metadata['interaction'] == 'focused'``). + + Args: + actions_df: A bundle or replay ``actions_df`` projection. + + Returns: + The subset of click rows excluding focus-only interactions. Rows with + no ``interaction`` metadata are kept (treated as genuine clicks). + """ + if actions_df.empty: + return actions_df + clicks: pd.DataFrame = actions_df[actions_df["action"] == "click"] + if clicks.empty: + return clicks + keep = clicks["metadata"].map(lambda m: (m or {}).get("interaction") != "focused") + filtered: pd.DataFrame = clicks[keep] + return filtered + + +def top_clicks(bundle: ReplayBundle, n: int = 10) -> pd.DataFrame: + """Top-N click targets across the bundle. + + Counts genuine clicks only: focus-only interactions are excluded via + :func:`real_clicks` so each user click counts once. + + Args: + bundle: The bundle to aggregate. + n: How many click targets to return. + + Returns: + DataFrame with columns ``target_desc``, ``count``, sorted + descending by count. + """ + clicks = real_clicks(bundle.actions_df) + if clicks.empty: + return pd.DataFrame(columns=["target_desc", "count"]) + grouped: pd.DataFrame = ( + clicks.groupby("target_desc", dropna=False) + .size() + .reset_index(name="count") + .sort_values("count", ascending=False) + .head(n) + .reset_index(drop=True) + ) + return grouped + + +def rage_clicks( + bundle: ReplayBundle, + threshold: int = 3, + window_ms: int = 1000, +) -> pd.DataFrame: + """Bursts of ≥ ``threshold`` clicks on the same target within ``window_ms``. + + Args: + bundle: The bundle to scan. + threshold: Minimum clicks per burst. Default 3. + window_ms: Maximum span of the burst in milliseconds. Default 1000. + + Returns: + DataFrame with columns ``replay_id``, ``t_start``, ``target_desc``, + ``count`` — one row per rage burst. + """ + rows: list[dict[str, object]] = [] + for replay in bundle.replays: + # Drop focus-only interactions: the analyzer maps both a real click and + # its paired focus event to action="click", so counting the focus row + # inflates burst sizes. Same predicate real_clicks() uses. + clicks = [ + a + for a in replay.actions + if a.action == "click" + and (a.metadata or {}).get("interaction") != "focused" + ] + i = 0 + while i < len(clicks): + j = i + 1 + while ( + j < len(clicks) + and clicks[j].target_desc == clicks[i].target_desc + and clicks[j].timestamp - clicks[i].timestamp <= window_ms + ): + j += 1 + burst = j - i + if burst >= threshold: + rows.append( + { + "replay_id": replay.replay_id, + "t_start": clicks[i].timestamp, + "target_desc": clicks[i].target_desc, + "count": burst, + } + ) + i = j + else: + i += 1 + return pd.DataFrame(rows, columns=["replay_id", "t_start", "target_desc", "count"]) + + +def long_pauses(bundle: ReplayBundle, threshold_s: float = 10) -> pd.DataFrame: + """Idle stretches between consecutive actions longer than ``threshold_s``. + + Args: + bundle: The bundle to scan. + threshold_s: Minimum pause length in seconds. Default 10. + + Returns: + DataFrame with columns ``replay_id``, ``t_start``, ``duration_s``. + """ + threshold_ms = int(threshold_s * 1000) + rows: list[dict[str, object]] = [] + for replay in bundle.replays: + for prev, curr in zip(replay.actions, replay.actions[1:], strict=False): + gap_ms = curr.timestamp - prev.timestamp + if gap_ms >= threshold_ms: + rows.append( + { + "replay_id": replay.replay_id, + "t_start": prev.timestamp, + "duration_s": gap_ms / 1000.0, + } + ) + return pd.DataFrame(rows, columns=["replay_id", "t_start", "duration_s"]) + + +def error_sessions(bundle: ReplayBundle) -> list[str]: + """Replay IDs that emitted at least one ``console_error`` action. + + Args: + bundle: The bundle to scan. + + Returns: + List of replay IDs in input order. Empty when the bundle has no + console errors. + """ + return [ + replay.replay_id + for replay in bundle.replays + if any(a.action == "console_error" for a in replay.actions) + ] diff --git a/src/mixpanel_headless/_internal/replays/rrweb_analyzer.py b/src/mixpanel_headless/_internal/replays/rrweb_analyzer.py new file mode 100644 index 00000000..7234d4e3 --- /dev/null +++ b/src/mixpanel_headless/_internal/replays/rrweb_analyzer.py @@ -0,0 +1,969 @@ +"""rrweb event-stream analyzer (044-session-replay). + +Walks the raw rrweb event stream, maintains DOM state, and emits two +parallel outputs from a single pass: + +- a list of :class:`mixpanel_headless.types.UserAction` records (the + structured surface that :class:`ReplayBundle` aggregations consume), and +- a plain-text markdown timeline (``{timestamp_seconds}: {description}`` + per line) for stdout / LLM consumption. + +This module is a fork. The initial cut took its DOM tracker, debouncing +thresholds, mouse-interaction naming, and console-plugin event detection +from a similar analyzer used internally inside Mixpanel; from this point +on it lives entirely in this repo and evolves on its own cadence. The +public surface here is wider than the initial source: :class:`AnalyzerResult` +exposes both the structured action list and the markdown string so +:class:`Workspace.fetch_replay` and :class:`ReplayBundle` can lean on +schema-stable activity labels. + +The structured-action mapping from internal interactions to public +``UserAction.action`` literals: + +- ``Navigated to {url}`` → ``navigate`` +- ``Clicked {desc}`` / ``Double-clicked`` / ``Right-clicked`` → ``click`` +- ``Focused {desc}`` → ``click`` (with ``metadata["interaction"]="focus"``) +- ``Tapped {desc}`` → ``touch_start`` +- ``Scrolled`` → ``scroll`` +- ``Set {desc} to {state}`` / ``Entered ... in {desc}`` / ``Modified + {desc}`` → ``input`` +- ``Selected '{text}'`` / ``Selected text`` → ``select`` +- ``Console error: {msg}`` → ``console_error`` +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any, cast +from urllib.parse import urlparse + +from mixpanel_headless.types import UserAction + +log = logging.getLogger(__name__) + + +# ============================================================================= +# rrweb event-shape enums +# ============================================================================= + + +class EventType(IntEnum): + """RRWeb event types.""" + + FULL_SNAPSHOT = 2 + INCREMENTAL_SNAPSHOT = 3 + META = 4 + PLUGIN = 6 + + +class IncrementalSource(IntEnum): + """RRWeb IncrementalSnapshot.data.source discriminators we handle.""" + + MUTATION = 0 + MOUSE_INTERACTION = 2 + SCROLL = 3 + INPUT = 5 + SELECTION = 14 + + +class MouseInteractionType(IntEnum): + """MouseInteraction.data.type values we emit actions for.""" + + CLICK = 2 + CONTEXT_MENU = 3 + DBL_CLICK = 4 + FOCUS = 5 + TOUCH_START = 7 + + +class NodeType(IntEnum): + """rrweb DOM node types.""" + + ELEMENT = 2 + TEXT = 3 + + +# ============================================================================= +# Public result types +# ============================================================================= + + +@dataclass(frozen=True) +class PageVisit: + """A single page navigation extracted from Meta events. + + Attributes: + timestamp: Unix ms timestamp of the Meta event. + url: The navigated-to URL. + """ + + timestamp: int + url: str + + +@dataclass(frozen=True) +class ConsoleError: + """A console-error log entry extracted from the rrweb console plugin. + + Attributes: + timestamp: Unix ms timestamp. + message: Joined message text. + url: Active page URL at the time of the error (None if unknown). + """ + + timestamp: int + message: str + url: str | None = None + + +@dataclass(frozen=True) +class AnalyzerResult: + """The full bundle returned by :meth:`RrwebAnalyzer.analyze`. + + Attributes: + actions: Structured :class:`UserAction` records in timestamp order; + consumed by :class:`ReplayBundle` aggregations. + markdown_summary: Plain-text markdown timeline + (``{timestamp_seconds}: {description}`` per line). + pages: Each Meta navigation as a :class:`PageVisit`. + errors: Console errors emitted during the session. + """ + + actions: list[UserAction] = field(default_factory=list) + markdown_summary: str = "" + pages: list[PageVisit] = field(default_factory=list) + errors: list[ConsoleError] = field(default_factory=list) + + +# ============================================================================= +# DOMTracker +# ============================================================================= + + +def _selector_attrs(sanitized_attrs: dict[str, Any]) -> dict[str, str]: + """Pick the stable ``data-*`` selector attributes from a node's attrs. + + These are the test-id-style hooks (``data-testid``, ``data-cy``, + ``data-qa``, …) that :func:`mixpanel_headless.selector_label_fn` reads off + ``UserAction.metadata``. Capturing every ``data-*`` attribute keeps the + public helper working for whatever convention a project actually uses, + rather than a hard-coded allowlist. + + Args: + sanitized_attrs: A node's already-sanitized ``{attr: value}`` map. + + Returns: + The subset whose keys start with ``data-`` and whose values are + non-empty strings. Empty when the node carries no such attribute. + """ + return { + k: v + for k, v in sanitized_attrs.items() + if k.startswith("data-") and isinstance(v, str) and v + } + + +class DOMTracker: + """Lightweight DOM state tracker. + + Tracks all nodes with metadata needed for user-action descriptions. + Walks ``FullSnapshot`` roots, applies ``Mutation.adds`` / removes / + text-changes / attribute-changes, and exposes + :meth:`get_node_description` for human-readable element labels. + """ + + INTERACTIVE_TAGS = { + "button", + "a", + "input", + "textarea", + "select", + "form", + "video", + "audio", + "svg", + "img", + "canvas", + } + + DESCRIPTIVE_ATTRS = [ + "aria-label", + "title", + "alt", + "placeholder", + "href", + "id", + "type", + ] + + MAX_ANCESTOR_DEPTH = 3 + MAX_NODES = 50000 + + def __init__(self) -> None: + """Initialize an empty node map + description cache.""" + self.nodes: dict[int, dict[str, Any]] = {} + self._description_cache: dict[int, str] = {} + self.reached_max_nodes = False + + @staticmethod + def _sanitize_value(value: Any) -> Any: + """Strip / drop trivially uninformative string values (empty, 'none').""" + if isinstance(value, str): + stripped = value.strip() + if not stripped or stripped.lower() == "none": + return "" + return stripped + return value + + def add_node(self, node: dict[str, Any], parent_id: int | None = None) -> None: + """Walk a FullSnapshot / mutation-add root and record element nodes. + + Args: + node: The rrweb node dict to ingest. + parent_id: Optional parent rrweb node id for ancestor traversal. + """ + queue: list[tuple[dict[str, Any], int | None]] = [(node, parent_id)] + + while queue: + current_node, current_parent_id = queue.pop(0) + + node_id = current_node.get("id") + if node_id is None: + continue + + if node_id not in self.nodes and len(self.nodes) >= self.MAX_NODES: + # Skip every new node once at the cap — and stop descending into + # its subtree (we `continue` before enqueuing children). The + # reached_max_nodes flag only de-dupes the log; it must NOT gate + # the skip itself, or only the first over-limit node is dropped + # and the map grows past MAX_NODES unbounded. + # + # Expected on large real sessions (complex SPA full-snapshots + # routinely exceed MAX_NODES); the analyzer degrades gracefully. + # DEBUG, not WARNING — this matches the upstream analyzer's + # intent and isn't actionable for callers. + if not self.reached_max_nodes: + log.debug( + "DOMTracker reached maximum node limit; skipping new nodes" + ) + self.reached_max_nodes = True + continue + + node_type = current_node.get("type") + + self.nodes[node_id] = { + "type": node_type, + "parent_id": current_parent_id, + } + + if node_type == NodeType.ELEMENT: + tag_name = current_node.get("tagName", "").lower() + attributes = current_node.get("attributes", {}) + self.nodes[node_id]["tag"] = tag_name + sanitized_attrs = { + k: v for k, v in attributes.items() if self._sanitize_value(v) + } + descriptive_attrs = { + attr: sanitized_attrs[attr] + for attr in self.DESCRIPTIVE_ATTRS + if attr in sanitized_attrs + } + + if descriptive_attrs: + self.nodes[node_id]["attributes"] = descriptive_attrs + + selector_attrs = _selector_attrs(sanitized_attrs) + if selector_attrs: + self.nodes[node_id]["selectors"] = selector_attrs + + if tag_name in self.INTERACTIVE_TAGS: + self.nodes[node_id]["text"] = self._extract_text(current_node) + + elif node_type == NodeType.TEXT: + text_content = self._sanitize_value(current_node.get("textContent", "")) + if text_content: + self.nodes[node_id]["text"] = text_content + if ( + current_parent_id + and current_parent_id in self.nodes + and "text" in self.nodes[current_parent_id] + ): + self.nodes[current_parent_id]["text"] = text_content + + for child in current_node.get("childNodes", []): + queue.append((child, node_id)) + + def _extract_text(self, node: dict[str, Any]) -> str: + """Concatenate direct text-child content for an interactive element.""" + texts: list[str] = [] + for child in node.get("childNodes", []): + if child.get("type") == NodeType.TEXT: + text = self._sanitize_value(child.get("textContent", "")) + if text: + texts.append(text) + return " ".join(texts) + + def remove_node(self, node_id: int) -> None: + """Drop a node + its cached description (mutation remove).""" + self.nodes.pop(node_id, None) + self._description_cache.pop(node_id, None) + + def update_text(self, node_id: int, text: str) -> None: + """Update the text of a node + its interactive ancestor, if any.""" + sanitized_text = self._sanitize_value(text) + if node_id in self.nodes: + if sanitized_text: + self.nodes[node_id]["text"] = sanitized_text + else: + self.nodes[node_id].pop("text", None) + self._description_cache.pop(node_id, None) + + parent_id = self.nodes.get(node_id, {}).get("parent_id") + if parent_id and parent_id in self.nodes and "text" in self.nodes[parent_id]: + if sanitized_text: + self.nodes[parent_id]["text"] = sanitized_text + else: + self.nodes[parent_id].pop("text", None) + self._description_cache.pop(parent_id, None) + + def update_attributes(self, node_id: int, attributes: dict[str, Any]) -> None: + """Merge new descriptive attributes onto an existing node.""" + sanitized_attrs = { + k: v for k, v in attributes.items() if self._sanitize_value(v) + } + descriptive_attrs = { + attr: sanitized_attrs[attr] + for attr in self.DESCRIPTIVE_ATTRS + if attr in sanitized_attrs + } + if node_id in self.nodes: + if "attributes" not in self.nodes[node_id]: + self.nodes[node_id]["attributes"] = {} + self.nodes[node_id]["attributes"].update(descriptive_attrs) + selector_attrs = _selector_attrs(sanitized_attrs) + if selector_attrs: + self.nodes[node_id].setdefault("selectors", {}).update(selector_attrs) + self._description_cache.pop(node_id, None) + + def get_node_selectors(self, node_id: int) -> dict[str, str]: + """Return the node's captured ``data-*`` selector attributes. + + These are the stable identifiers (``data-testid``, ``data-cy``, …) + that :func:`mixpanel_headless.selector_label_fn` consults on + ``UserAction.metadata``. Empty when the node was never recorded or + carried no ``data-*`` attribute. + + Args: + node_id: The rrweb node id. + + Returns: + A ``{attr: value}`` dict of the node's ``data-*`` attributes (a + fresh copy; safe for the caller to merge into action metadata). + """ + node = self.nodes.get(node_id) + if not node: + return {} + selectors = node.get("selectors") + if not selectors: + return {} + return {str(k): str(v) for k, v in selectors.items()} + + def get_node_description(self, node_id: int) -> str: + """Best-effort human-readable description of a node. + + Returns a description built from the node's own tag / attributes / + text, falling back to ancestor context, then to the literal + ``"element"`` sentinel. + """ + if node_id in self._description_cache: + return self._description_cache[node_id] + + direct_desc = self._build_node_description(node_id) + if direct_desc: + self._description_cache[node_id] = direct_desc + return direct_desc + + ancestor_desc = self._get_ancestor_context(node_id) + if ancestor_desc: + self._description_cache[node_id] = ancestor_desc + return ancestor_desc + + fallback = "element" + self._description_cache[node_id] = fallback + return fallback + + def _build_node_description(self, node_id: int) -> str | None: + """Build a description from the node's own metadata, if any. + + Returns: + The description string, or None when the node carries no + meaningful descriptive info (caller falls back to ancestor + traversal). + """ + if node_id not in self.nodes: + return None + + node_data = self.nodes[node_id] + tag = node_data.get("tag", "element") + attrs = node_data.get("attributes", {}) + text = node_data.get("text", "") + parts: list[str] = [tag] + has_meaningful_info = False + + if attrs.get("aria-label") is not None: + parts.append(f'"{attrs["aria-label"]}"') + has_meaningful_info = True + elif attrs.get("title") is not None: + parts.append(f'"{attrs["title"]}"') + has_meaningful_info = True + elif attrs.get("alt") is not None: + parts.append(f'alt="{attrs["alt"]}"') + has_meaningful_info = True + elif text: + parts.append(f'"{text}"') + has_meaningful_info = True + elif attrs.get("placeholder") is not None: + parts.append(f'placeholder="{attrs["placeholder"]}"') + has_meaningful_info = True + + if attrs.get("href") is not None and tag == "a": + href = attrs["href"] + if href.startswith("http"): + try: + parsed = urlparse(href) + path = parsed.path + if path and path != "/": + parts.append(f"to {path}") + has_meaningful_info = True + except Exception: # noqa: BLE001 — defensively swallow URL parse failures + pass + + if attrs.get("id") is not None and not has_meaningful_info: + parts.append(f"#{attrs['id']}") + has_meaningful_info = True + + if tag == "input" and attrs.get("type") is not None: + parts.append(f"type={attrs['type']}") + has_meaningful_info = True + + if has_meaningful_info: + return " ".join(parts) + return None + + def _get_ancestor_context(self, node_id: int) -> str | None: + """Walk up to :data:`MAX_ANCESTOR_DEPTH` parents for descriptive context. + + Returns: + ``"{tag} in {parent_description}"`` when a describable ancestor + is reachable; None otherwise. + """ + if node_id not in self.nodes: + return None + + node_data = self.nodes[node_id] + tag = node_data.get("tag", "element") + + parent_id = node_data.get("parent_id") + depth = 0 + visited: set[int] = set() + + while parent_id and depth < self.MAX_ANCESTOR_DEPTH: + if parent_id in visited: + break + visited.add(parent_id) + + parent_desc = self._description_cache.get( + parent_id + ) or self._build_node_description(parent_id) + if parent_desc: + return f"{tag} in {parent_desc}" + + if parent_id in self.nodes: + parent_id = self.nodes[parent_id].get("parent_id") + depth += 1 + else: + break + + return None + + +# ============================================================================= +# EventAnalyzer — emits structured public UserAction + description lines +# ============================================================================= + + +# Maps MouseInteractionType to a human-readable verb used in description strings. +_MOUSE_INTERACTION_NAMES: dict[int, str] = { + int(MouseInteractionType.CLICK): "clicked", + int(MouseInteractionType.DBL_CLICK): "double-clicked", + int(MouseInteractionType.CONTEXT_MENU): "right-clicked", + int(MouseInteractionType.FOCUS): "focused", + int(MouseInteractionType.TOUCH_START): "tapped", +} + +# Maps the human-readable verb to the public UserAction.action literal. +# All click-family interactions collapse to "click" so ReplayBundle +# aggregations (top_clicks, rage_clicks) work uniformly; +# the original interaction is preserved in metadata["interaction"]. +_INTERACTION_TO_ACTION: dict[str, str] = { + "clicked": "click", + "double-clicked": "click", + "right-clicked": "click", + "focused": "click", + "tapped": "touch_start", +} + + +class EventAnalyzer: + """Single-pass rrweb event walker emitting structured + textual actions. + + Applies per-source debouncing (scroll / input / selection at 1s each) + and plugin-event filtering for ``rrweb/console@*`` console errors. + Emits the public :class:`mixpanel_headless.types.UserAction` so + downstream aggregations keep their schema-stable action literals. + """ + + SCROLL_DEBOUNCE_MS = 1000 + SELECTION_DEBOUNCE_MS = 1000 + INPUT_DEBOUNCE_MS = 1000 + + def __init__(self, dom_tracker: DOMTracker | None = None) -> None: + """Initialize the analyzer with an optional pre-seeded DOM tracker.""" + self.dom_tracker = dom_tracker or DOMTracker() + self.user_actions: list[UserAction] = [] + # Parallel list of (timestamp_ms, description) pairs for the markdown + # reporter — kept distinct from user_actions so we render the + # `{ts}: {desc}` line format directly instead of reverse-engineering + # it from the structured UserAction objects. + self.descriptions: list[tuple[int, str]] = [] + self.pages: list[PageVisit] = [] + self.errors: list[ConsoleError] = [] + self.current_url: str | None = None + self.last_scroll_time = 0 + self.last_selection_time = 0 + self.last_input_time: dict[int, int] = {} + + def _emit( + self, + timestamp: int, + action: str, + description: str, + *, + target_node_id: int | None = None, + target_desc: str | None = None, + url: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Append both a structured UserAction and a (timestamp, description) line. + + Args: + timestamp: Unix ms. + action: One of the public ``UserAction.action`` literal values. + description: Human-readable description text for the markdown line. + target_node_id: rrweb node id, if applicable. + target_desc: Human-readable element label (defaults to the + description when not provided). + url: Active page URL. + metadata: Action-specific extras. + """ + self.descriptions.append((timestamp, description)) + self.user_actions.append( + UserAction( + timestamp=timestamp, + action=cast(Any, action), + target_node_id=target_node_id, + target_desc=target_desc or description, + url=url if url is not None else self.current_url, + metadata=metadata or {}, + description=description, + ) + ) + + def process_event(self, event: dict[str, Any]) -> None: + """Dispatch a single rrweb event to its type-specific handler.""" + event_type = event.get("type") + timestamp = int(event.get("timestamp", 0)) + raw_data = event.get("data") + data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} + + if event_type == EventType.META: + self._process_meta(timestamp, data) + elif event_type == EventType.FULL_SNAPSHOT: + self._process_full_snapshot(timestamp, data) + elif event_type == EventType.INCREMENTAL_SNAPSHOT: + self._process_incremental_snapshot(timestamp, data) + elif event_type == EventType.PLUGIN: + self._process_plugin_event(timestamp, data) + + def _process_meta(self, timestamp: int, data: dict[str, Any]) -> None: + """Handle navigations (Meta events): update current URL + emit action.""" + url = data.get("href") + if url: + self.current_url = url + self.pages.append(PageVisit(timestamp=timestamp, url=url)) + self._emit( + timestamp, + "navigate", + f"Navigated to {url}", + url=url, + metadata={"url": url}, + ) + + def _process_full_snapshot(self, timestamp: int, data: dict[str, Any]) -> None: + """Ingest a FullSnapshot root into the DOM tracker; emit no action.""" + _ = timestamp + node = data.get("node") + if node: + self.dom_tracker.add_node(node) + + def _process_incremental_snapshot( + self, timestamp: int, data: dict[str, Any] + ) -> None: + """Route incremental snapshots by their `data.source` discriminator.""" + source = data.get("source") + if source == IncrementalSource.MUTATION: + self._process_mutation(timestamp, data) + elif source == IncrementalSource.MOUSE_INTERACTION: + self._process_mouse_interaction(timestamp, data) + elif source == IncrementalSource.SCROLL: + self._process_scroll(timestamp, data) + elif source == IncrementalSource.INPUT: + self._process_input(timestamp, data) + elif source == IncrementalSource.SELECTION: + self._process_selection(timestamp, data) + + def _process_mutation(self, timestamp: int, data: dict[str, Any]) -> None: + """Apply Mutation adds / removes / texts / attributes to the DOM tracker.""" + _ = timestamp + for add in data.get("adds", []) or []: + node = add.get("node") + parent_id = add.get("parentId") + if node: + self.dom_tracker.add_node(node, parent_id) + for remove in data.get("removes", []) or []: + node_id = remove.get("id") + if node_id: + self.dom_tracker.remove_node(node_id) + for text_change in data.get("texts", []) or []: + node_id = text_change.get("id") + value = text_change.get("value") + if node_id and value: + self.dom_tracker.update_text(node_id, value) + for attr_change in data.get("attributes", []) or []: + node_id = attr_change.get("id") + attributes = attr_change.get("attributes") + if node_id and attributes: + self.dom_tracker.update_attributes(node_id, attributes) + + def _process_mouse_interaction(self, timestamp: int, data: dict[str, Any]) -> None: + """Emit click-family / focus / touch-start actions for interactions.""" + interaction_type = data.get("type") + node_id = data.get("id") + + if not isinstance(interaction_type, int): + return + verb = _MOUSE_INTERACTION_NAMES.get(interaction_type) + if not verb: + return + + node_desc = ( + self.dom_tracker.get_node_description(node_id) + if node_id is not None + else "unknown element" + ) + if node_desc == "unknown element": + return + + action_literal = _INTERACTION_TO_ACTION.get(verb, "click") + metadata: dict[str, Any] = {"interaction": verb} + if isinstance(node_id, int): + # Surface the element's data-* selectors so selector_label_fn can + # group by a stable test id instead of falling through to the URL. + metadata.update(self.dom_tracker.get_node_selectors(node_id)) + self._emit( + timestamp, + action_literal, + f"{verb.capitalize()} {node_desc}", + target_node_id=node_id if isinstance(node_id, int) else None, + target_desc=node_desc, + metadata=metadata, + ) + + def _process_scroll(self, timestamp: int, data: dict[str, Any]) -> None: + """Emit a debounced scroll action (one per :data:`SCROLL_DEBOUNCE_MS`).""" + _ = data + if timestamp - self.last_scroll_time > self.SCROLL_DEBOUNCE_MS: + self._emit(timestamp, "scroll", "Scrolled", target_desc="(viewport)") + self.last_scroll_time = timestamp + + def _process_input(self, timestamp: int, data: dict[str, Any]) -> None: + """Emit a debounced input action (per-node, :data:`INPUT_DEBOUNCE_MS`).""" + node_id = data.get("id") + text = data.get("text", "") + is_checked = data.get("isChecked") + + if node_id is not None: + last_time = self.last_input_time.get(node_id, 0) + if timestamp - last_time <= self.INPUT_DEBOUNCE_MS: + return + self.last_input_time[node_id] = timestamp + + node_desc = ( + self.dom_tracker.get_node_description(node_id) + if node_id is not None + else "input" + ) + + if is_checked is not None: + state = "checked" if is_checked else "unchecked" + description = f"Set {node_desc} to {state}" + elif text: + description = f"Entered '{text}' in {node_desc}" + else: + description = f"Modified {node_desc}" + + metadata: dict[str, Any] = { + "text_length": len(text) if isinstance(text, str) else 0, + "is_checked": is_checked, + } + if isinstance(node_id, int): + metadata.update(self.dom_tracker.get_node_selectors(node_id)) + self._emit( + timestamp, + "input", + description, + target_node_id=node_id if isinstance(node_id, int) else None, + target_desc=node_desc, + metadata=metadata, + ) + + def _process_selection(self, timestamp: int, data: dict[str, Any]) -> None: + """Emit a debounced text-selection action when the user selects text.""" + ranges = data.get("ranges", []) + if not ranges: + return + + if timestamp - self.last_selection_time > self.SELECTION_DEBOUNCE_MS: + selected_texts: list[str] = [] + + for range_data in ranges: + start_node_id = range_data.get("start") + end_node_id = range_data.get("end") + start_offset = range_data.get("startOffset", 0) + end_offset = range_data.get("endOffset", 0) + + if ( + start_node_id == end_node_id + and start_node_id + and start_node_id in self.dom_tracker.nodes + ): + node_data = self.dom_tracker.nodes[start_node_id] + if "text" in node_data: + text_content = node_data["text"] + text = text_content[start_offset:end_offset].strip() + if text: + selected_texts.append(text) + + if selected_texts: + combined = " ... ".join(selected_texts) + description = f"Selected '{combined}'" + else: + description = "Selected text" + + self._emit( + timestamp, + "select", + description, + target_desc="(selection)", + metadata={"range_count": len(ranges)}, + ) + self.last_selection_time = timestamp + + def _process_plugin_event(self, timestamp: int, data: dict[str, Any]) -> None: + """Emit console_error actions for `rrweb/console@*` plugin payloads.""" + plugin = data.get("plugin", "") + if not plugin.startswith("rrweb/console@"): + return + + payload = data.get("payload", {}) + level = payload.get("level", "") + if level != "error": + return + + messages = payload.get("payload", []) + if not messages: + return + + message = " ".join(str(m).strip('"') for m in messages) + if not message: + return + + self.errors.append( + ConsoleError(timestamp=timestamp, message=message, url=self.current_url) + ) + self._emit( + timestamp, + "console_error", + f"Console error: {message}", + target_desc=message, + metadata={"message": message}, + ) + + +# ============================================================================= +# Markdown reporter +# ============================================================================= + + +def _collapse_timeline(lines: list[tuple[int, str]]) -> str: + """Render ``{ts_seconds}: {description}`` lines, collapsing runs. + + Consecutive entries with an identical description are coalesced into a + single line with a ``(×N)`` suffix — e.g. a data grid that re-renders the + same cell 67 times becomes one line, not 67. The timestamp shown is the + first in the run. + + Args: + lines: ``(timestamp_ms, description)`` pairs in timeline order. + + Returns: + Newline-joined markdown; ``""`` for empty input. + """ + out: list[str] = [] + i = 0 + n = len(lines) + while i < n: + ts, desc = lines[i] + j = i + 1 + while j < n and lines[j][1] == desc: + j += 1 + run = j - i + suffix = f" (×{run})" if run > 1 else "" + out.append(f"{ts // 1000}: {desc}{suffix}") + i = j + return "\n".join(out) + + +class MarkdownReporter: + """Render ``{ts_seconds}: {description}`` lines from a description list.""" + + def __init__(self, descriptions: list[tuple[int, str]]) -> None: + """Initialize with parallel (timestamp_ms, description) pairs.""" + self.descriptions = descriptions + + def generate(self) -> str: + """Produce the markdown string, collapsing consecutive duplicates. + + Returns: + ``"No user actions recorded."`` for an empty list; otherwise + one line per (timestamp, description) run via + :func:`_collapse_timeline`. + """ + if not self.descriptions: + return "No user actions recorded." + return _collapse_timeline(self.descriptions) + + +# ============================================================================= +# Public entry point +# ============================================================================= + + +class RrwebAnalyzer: + """Convert a raw rrweb event stream into normalized actions + markdown. + + Stateless across calls: each :meth:`analyze` invocation constructs its + own :class:`DOMTracker` + :class:`EventAnalyzer`. Inputs are not + mutated; events are sorted by timestamp before processing. + + Example: + ```python + analyzer = RrwebAnalyzer() + result = analyzer.analyze(rrweb_events) + for action in result.actions: + print(action.timestamp, action.action, action.target_desc) + print(result.markdown_summary) + ``` + """ + + def analyze(self, events: list[dict[str, Any]]) -> AnalyzerResult: + """Walk ``events`` once and produce the :class:`AnalyzerResult`. + + Args: + events: Raw rrweb event dicts. Order doesn't matter — the + analyzer sorts a shallow copy by ``timestamp`` before + walking. + + Returns: + An :class:`AnalyzerResult` with the action list, markdown + timeline, page visits, and console errors populated. Empty + on empty input. + """ + if not events: + return AnalyzerResult() + + sorted_events = sorted(events, key=lambda e: int(e.get("timestamp", 0))) + + dom_tracker = DOMTracker() + event_analyzer = EventAnalyzer(dom_tracker) + for event in sorted_events: + event_analyzer.process_event(event) + + markdown = MarkdownReporter(event_analyzer.descriptions).generate() + log.info("Generated %d user actions", len(event_analyzer.user_actions)) + return AnalyzerResult( + actions=event_analyzer.user_actions, + markdown_summary=markdown, + pages=event_analyzer.pages, + errors=event_analyzer.errors, + ) + + +def analyze_events(rrweb_events: list[dict[str, Any]]) -> str: + """Convenience entry: walk events + return the markdown string. + + Sugar for ``RrwebAnalyzer().analyze(events).markdown_summary`` for + callers that only want the markdown timeline. + + Args: + rrweb_events: List of rrweb event dicts. + + Returns: + The markdown timeline string. + + Raises: + ValueError: ``rrweb_events`` is empty or not a list. + """ + if not rrweb_events: + raise ValueError("Events list cannot be empty") + if not isinstance(rrweb_events, list): + raise ValueError("Events must be a list of dictionaries") + + log.info("Analyzing %d rrweb events", len(rrweb_events)) + return RrwebAnalyzer().analyze(rrweb_events).markdown_summary + + +# Used by Replay.summary_markdown to render a timeline from the structured +# action list. Each UserAction carries a full ``description`` (e.g. +# 'Clicked button "Sign in"'); ``target_desc`` is the fallback for actions +# built without the analyzer (hand-constructed fixtures). +def _render_markdown(actions: list[UserAction]) -> str: + """Render a markdown timeline from a structured action list. + + Renders each action's full ``description`` (falling back to + ``target_desc`` when empty) and collapses consecutive duplicates via + :func:`_collapse_timeline`, matching the analyzer's ``markdown_summary``. + + Args: + actions: Structured action list (may be empty). + + Returns: + Multi-line markdown string. Empty when ``actions`` is empty. + """ + if not actions: + return "" + return _collapse_timeline( + [(a.timestamp, a.description or a.target_desc) for a in actions] + ) diff --git a/src/mixpanel_headless/_internal/services/replays.py b/src/mixpanel_headless/_internal/services/replays.py new file mode 100644 index 00000000..cb129103 --- /dev/null +++ b/src/mixpanel_headless/_internal/services/replays.py @@ -0,0 +1,971 @@ +"""ReplaysService — the session-replay discovery → sign → fetch pipeline. + +Orchestrates the discovery → sign → fetch pipeline against the Mixpanel App API +and the signed CDN. Owned by :class:`Workspace`; not part of the public API. + +The service stays pure-bytes: +- ``sign`` — wraps :meth:`MixpanelAPIClient.sign_replays`, attaches a + ``signed_at`` timestamp, hydrates :class:`SignedReplay` instances. +- ``fetch_files`` / ``walk_cdn_async`` — parallel CDN walker with batched + ``httpx.AsyncClient`` GETs, 404-as-end-sentinel, 403-as-expiry retry, + mobile-replay detection, ``max_files`` upper bound. +- ``discover`` / ``events_for`` — Insights-API discovery for + ``$mp_session_record`` events (delegates to a caller-supplied ``query_fn`` + to avoid a circular dependency on :class:`Workspace`). + +The rrweb analyzer runs a layer above, in :meth:`Workspace.fetch_replay`, +which feeds the fetched bytes through the vendored analyzer to populate +``Replay.actions``. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import warnings +from collections.abc import AsyncGenerator, Callable +from typing import TYPE_CHECKING, Any, Literal + +import httpx + +from mixpanel_headless.exceptions import ( + MixpanelHeadlessError, + ReplayNotFoundError, + SignedURLExpiredError, + UnsupportedReplayFormatError, +) +from mixpanel_headless.types import ( + Filter, + ReplayEvent, + ReplaySummary, + SignedReplay, +) + +if TYPE_CHECKING: + from mixpanel_headless._internal.api_client import MixpanelAPIClient + +_logger = logging.getLogger(__name__) + + +# Allowed retention windows — surfaced as a sentinel so the discover-time +# warning path can default a missing $mp_replay_retention_period. +_DEFAULT_RETENTION_DAYS = 30 + +# Lookback for events_for when the caller doesn't pass an explicit window. +# 90 = the maximum replay retention window, so a windowless events query still +# covers every replay that could possibly still exist on the CDN. +_EVENTS_DEFAULT_LOOKBACK_DAYS = 90 + +# Per-request CDN timeout. Replaces a flat 120s that let one stalled read hang +# for two minutes (and, in fetch_replays, sink the whole bundle). connect=10s +# fails fast on a dead/slow host (the reference cdn_fetcher uses 10s); read=30s +# gives a slow-but-progressing file enough headroom not to be false-skipped on +# the single-replay path; pool=30s tolerates request queueing under batched +# concurrency. fetch_replays' continue-on-error is the primary resilience +# mechanism — this just bounds how long a single stall can cost. +_CDN_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=30.0) + + +def _looks_like_rrweb(event: object) -> bool: + """Heuristic: does this look like an rrweb web-recording event? + + rrweb event shape always includes at minimum ``type`` (int discriminator), + ``data`` (dict), and ``timestamp`` (int ms). Mobile session replays use a + different recording format that lacks these keys; we treat absence as + "not rrweb" and surface a forward-compat + :class:`UnsupportedReplayFormatError`. + + Args: + event: A single deserialized CDN-file element. + + Returns: + True when the event carries the three required rrweb keys; False + otherwise. + """ + if not isinstance(event, dict): + return False + return "type" in event and "data" in event and "timestamp" in event + + +def replay_not_found_error( + replay_id: str, *, retention_days: int, cdn_url_prefix: str +) -> ReplayNotFoundError: + """Build the canonical ``ReplayNotFoundError`` for an absent replay. + + Two call sites surface the same "no replay on the CDN" condition — the + walker's first-file 404 here, and ``Workspace.fetch_replay`` when the walk + yields zero events. Sharing one constructor keeps their message and + ``details`` shape from drifting. + + Args: + replay_id: The replay that could not be found. + retention_days: The retention window searched (1, 7, 30, or 90). + cdn_url_prefix: The signed CDN URL prefix that was walked. + + Returns: + A :class:`ReplayNotFoundError` with status 404 and structured details + (``replay_id`` / ``retention_days`` / ``cdn_url_prefix``). + """ + return ReplayNotFoundError( + ( + f"Replay {replay_id} not found on CDN. The replay may have aged " + f"out of its retention window ({retention_days} days), never been " + f"recorded, or been deleted." + ), + details={ + "replay_id": replay_id, + "retention_days": retention_days, + "cdn_url_prefix": cdn_url_prefix, + }, + status_code=404, + ) + + +class ReplaysService: + """Orchestrator for the session-replay pipeline. + + Sits between :class:`Workspace` and :class:`MixpanelAPIClient`; owns the + async CDN walker that pulls raw rrweb bytes. The service intentionally + stays pure-bytes — the rrweb analyzer runs a layer above, in + :meth:`Workspace.fetch_replay`, which feeds ``rrweb_events`` through the + vendored analyzer when populating :class:`Replay`. + + Construction is dependency-injected: the API client carries auth context, + the optional ``query_fn`` (typically a bound :meth:`Workspace.query`) + avoids a circular import while still letting discovery issue Insights + queries, and ``_async_transport`` lets tests swap in ``httpx.MockTransport``. + + Attributes: + _api: The bound API client used for signing and Insights calls. + _query_fn: Optional bound ``Workspace.query`` used by + :meth:`discover` and :meth:`events_for`. None disables those two + methods; sign/fetch keep working. + _logger: Logger; never receives ``query_string`` at any level + (FR-009). + _async_transport: Optional ``httpx.AsyncBaseTransport`` injected at + test time so CDN fetches go through ``httpx.MockTransport``. + """ + + def __init__( + self, + api_client: MixpanelAPIClient, + *, + query_fn: Callable[..., Any] | None = None, + logger: logging.Logger | None = None, + _async_transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + """Initialize the service. + + Args: + api_client: Authenticated :class:`MixpanelAPIClient`. + query_fn: Bound :meth:`Workspace.query` for discovery queries. + Optional — when omitted, :meth:`discover` and + :meth:`events_for` raise ``RuntimeError``. + logger: Optional logger; defaults to the module logger. + _async_transport: Optional async transport for CDN fetches + (test-only seam). + """ + self._api = api_client + self._query_fn = query_fn + self._logger = logger or _logger + self._async_transport = _async_transport + + # ========================================================================= + # Sign + # ========================================================================= + + def sign( + self, + replay_ids: list[str], + env: Literal["prod", "dev"] = "prod", + ) -> list[SignedReplay]: + """Sign one or more replay IDs for CDN access. + + Captures ``signed_at`` BEFORE issuing the request so callers' expiry + arithmetic is conservative (the URL stops working slightly before + ``signed_at + 300`` — the server's stopwatch starts earlier than the + client's record). Delegates raw signing to + :meth:`MixpanelAPIClient.sign_replays` which handles the 403→ + :class:`SessionReplayAccessError` mapping. + + Args: + replay_ids: Replay IDs to sign, in any order. + env: ``"prod"`` (default) or ``"dev"``. + + Returns: + List of :class:`SignedReplay`, in input order. The + ``query_string`` field is a bearer credential — never log it. + + Raises: + SessionReplayAccessError: Project has + ``SESSION_RECORDING_SENSITIVE_DATA`` enabled and caller + lacks ``sensitive_data_replay`` permission. + APIError: Other 4xx (other than the sensitive-data 403) or + ``ServerError`` on 5xx. + """ + signed_at = time.time() + raw = self._api.sign_replays(replay_ids, env=env) + return [ + SignedReplay( + replay_id=str(item["replay_id"]), + url=str(item["url"]), + query_string=str(item["query_string"]), + env=env, + signed_at=signed_at, + ) + for item in raw + ] + + # ========================================================================= + # Fetch / walk + # ========================================================================= + + def fetch_files( + self, + signed: SignedReplay, + *, + retention_days: int, + max_files: int = 500, + concurrency: int = 50, + re_sign_on_expiry: bool = True, + ) -> list[dict[str, Any]]: + """Buffered parallel fetch of all CDN files for a replay. + + Walks ``{signed.url}{N:04d}-{retention_days}.json?{query_string}`` + in batches of ``concurrency`` until either a 404 sentinel terminates + the walk or ``max_files`` is reached. Returns the concatenated + rrweb event stream sorted by timestamp. + + Args: + signed: Signed CDN access handle from :meth:`sign`. + retention_days: 1, 7, 30, or 90 — drives the file-name suffix + per FR-013. + max_files: Hard upper bound on the walk (default 500). + concurrency: Parallel batch size (default 50). + re_sign_on_expiry: When True, transparently re-signs once if a + batch returns 403. When False, raises + :class:`SignedURLExpiredError`. + + Returns: + Timestamp-sorted list of raw rrweb event dicts. + + Raises: + ReplayNotFoundError: First CDN file (``0000-N.json``) was 404. + SignedURLExpiredError: Re-sign retry also returned 403, or + ``re_sign_on_expiry=False``. + UnsupportedReplayFormatError: First event in the walk doesn't look + like rrweb (mobile replay or unknown format). + MixpanelHeadlessError: Network errors during CDN fetch. + """ + + async def _runner() -> list[dict[str, Any]]: + """Collect every event yielded by walk_cdn_async into a buffer.""" + collected: list[dict[str, Any]] = [] + async for ev in self.walk_cdn_async( + signed, + retention_days=retention_days, + max_files=max_files, + concurrency=concurrency, + re_sign_on_expiry=re_sign_on_expiry, + ): + collected.append(ev) + return collected + + return asyncio.run(_runner()) + + async def walk_cdn_async( + self, + signed: SignedReplay, + *, + retention_days: int, + max_files: int = 500, + concurrency: int = 50, + re_sign_on_expiry: bool = True, + ) -> AsyncGenerator[dict[str, Any], None]: + """Streaming parallel walk of CDN files; yields rrweb events lazily. + + Algorithm (per FR-011, FR-012, FR-014, FR-015): + 1. Fetch files ``[N, N+concurrency)`` in parallel via + ``asyncio.gather``. + 2. If any 403 hits and ``re_sign_on_expiry=True``, re-sign once and + retry the batch; otherwise raise :class:`SignedURLExpiredError`. + 3. Walk the batch in file-number order. First file (file_num=0) + returning 404 raises :class:`ReplayNotFoundError`; any subsequent + 404 terminates the walk cleanly (end-of-replay sentinel). + 4. Within each surviving file, yield events sorted by ``timestamp``. + 5. Continue until termination, exhaustion, or ``max_files``. + + Ordering note: events are yielded in ``(file-number, then in-file + timestamp)`` order — there is no global merge across files. CDN files + are chronologically partitioned in practice, so the stream is + effectively timestamp-ordered, but callers that need a hard global + sort should not rely on it. + + Mobile-replay detection runs once on the very first event of the + walk: if it lacks rrweb's ``type``/``data``/``timestamp`` keys, + raises :class:`UnsupportedReplayFormatError` per error-messages.md §9. + + Args: + signed: Signed CDN access handle. + retention_days: 1, 7, 30, or 90. + max_files: Hard upper bound on the walk. + concurrency: Parallel batch size. + re_sign_on_expiry: Re-sign once on 403; raise otherwise. + + Yields: + Raw rrweb event dicts in ``(file-number, then in-file timestamp)`` + order (see the ordering note above). + + Raises: + ReplayNotFoundError: First CDN file 404. + SignedURLExpiredError: Expiry retry exhausted or disabled. + UnsupportedReplayFormatError: First event isn't rrweb-shaped. + MixpanelHeadlessError: Underlying CDN HTTP error. + """ + current_signed = signed + file_num = 0 + mobile_checked = False + re_signed_once = False + + async with httpx.AsyncClient( + transport=self._async_transport, + timeout=_CDN_TIMEOUT, + ) as client: + while file_num < max_files: + batch_end = min(file_num + concurrency, max_files) + batch_nums = list(range(file_num, batch_end)) + + results = await self._fetch_batch( + client, current_signed, batch_nums, retention_days + ) + + # 403 retry: re-sign once if any file in the batch expired. + if any(status == 403 for status, _ in results): + if not re_sign_on_expiry or re_signed_once: + raise self._build_expired_error(signed) + re_signed_once = True + fresh = self.sign([signed.replay_id], env=signed.env) + current_signed = fresh[0] + results = await self._fetch_batch( + client, current_signed, batch_nums, retention_days + ) + if any(status == 403 for status, _ in results): + raise self._build_expired_error(signed) + + # Walk results in file-number order. A 404 mid-walk is the + # clean end-of-replay sentinel; a 404 on file 0 means the + # replay simply doesn't exist on the CDN. + terminate_at = len(results) + for i, (status, _events) in enumerate(results): + if status == 404: + if file_num + i == 0: + raise replay_not_found_error( + signed.replay_id, + retention_days=retention_days, + cdn_url_prefix=signed.url, + ) + terminate_at = i + break + + # Yield events from the surviving files in timestamp order. + for i in range(terminate_at): + status, events = results[i] + if status != 200 or not events: + continue + if not mobile_checked: + mobile_checked = True + if not _looks_like_rrweb(events[0]): + raise UnsupportedReplayFormatError( + f"Replay {signed.replay_id} appears to be a " + f"mobile session (non-rrweb format). Mobile " + f"session replays are not yet supported by " + f"mixpanel-headless. Track upstream at SR-230.", + details={ + "replay_id": signed.replay_id, + "format": "non-rrweb", + }, + ) + for ev in sorted(events, key=lambda e: int(e.get("timestamp", 0))): + yield ev + + if terminate_at < len(results): + return + file_num = batch_end + + async def _fetch_batch( + self, + client: httpx.AsyncClient, + signed: SignedReplay, + file_nums: list[int], + retention_days: int, + ) -> list[tuple[int, list[dict[str, Any]] | None]]: + """Issue parallel CDN GETs for a batch of file numbers. + + Args: + client: An ``httpx.AsyncClient`` (created by + :meth:`walk_cdn_async` with the test-injectable transport). + signed: The active signed handle (may be a re-signed instance). + file_nums: File numbers to fetch (e.g. ``[0, 1, 2, ..., 49]``). + retention_days: 1, 7, 30, or 90 — used in the file-name suffix. + + Returns: + List of ``(status_code, events_or_none)`` tuples in + ``file_nums`` order. ``events`` is the decoded JSON list for + 200s, ``None`` for 404 and 403. + """ + tasks = [self._fetch_one(client, signed, n, retention_days) for n in file_nums] + return await asyncio.gather(*tasks) + + async def _fetch_one( + self, + client: httpx.AsyncClient, + signed: SignedReplay, + file_num: int, + retention_days: int, + ) -> tuple[int, list[dict[str, Any]] | None]: + """Fetch a single CDN file. + + URL pattern per FR-013: + ``{signed.url}{file_num:04d}-{retention_days}.json?{query_string}``. + + Args: + client: The active ``httpx.AsyncClient``. + signed: Signed access handle providing url + credential. + file_num: Zero-padded file index (0–9999). + retention_days: Retention suffix. + + Returns: + ``(status_code, events_or_none)``: events list for 200s, + ``None`` for 404 and 403, raises for everything else. + + Raises: + MixpanelHeadlessError: Non-200/404/403 HTTP error or transport + failure during the CDN fetch. + """ + # NB: query_string is a bearer credential — never log this URL. + url = f"{signed.url}{file_num:04d}-{retention_days}.json?{signed.query_string}" + try: + response = await client.get(url) + except httpx.HTTPError as exc: + # str(exc) can embed the request URL (e.g. UnsupportedProtocol / + # InvalidURL subclasses), and our URL carries the signed + # query_string bearer credential. Scrub it before it lands in an + # exception message or log. (The chained __cause__ is kept for + # debugging; transport errors rarely stringify the URL.) + safe = str(exc).replace(signed.query_string, "") + raise MixpanelHeadlessError( + f"CDN fetch failed for file {file_num:04d}: {safe}", + code="CDN_FETCH_ERROR", + ) from exc + + if response.status_code == 200: + try: + payload = response.json() + except ValueError as exc: + raise MixpanelHeadlessError( + f"CDN file {file_num:04d} returned non-JSON: {exc}", + code="CDN_INVALID_RESPONSE", + ) from exc + events = payload if isinstance(payload, list) else [] + return (200, events) + if response.status_code in (403, 404): + return (response.status_code, None) + raise MixpanelHeadlessError( + f"CDN file {file_num:04d} returned unexpected status " + f"{response.status_code}", + code="CDN_UNEXPECTED_STATUS", + ) + + def _build_expired_error(self, signed: SignedReplay) -> SignedURLExpiredError: + """Construct a canonical :class:`SignedURLExpiredError` for ``signed``. + + Args: + signed: The signed handle whose URL expired. + + Returns: + A :class:`SignedURLExpiredError` with the catalog message and + the ``replay_id``/``signed_at``/``expired_at`` details. + """ + return SignedURLExpiredError( + ( + f"Signed URL for replay {signed.replay_id} expired (5-minute " + f"TTL). Re-sign with sign_replay({signed.replay_id!r}) or use " + f"the default re_sign_on_expiry=True on stream_replay." + ), + details={ + "replay_id": signed.replay_id, + "signed_at": signed.signed_at, + "expired_at": signed.expires_at, + }, + status_code=403, + ) + + # ========================================================================= + # Discovery + events + # ========================================================================= + + def discover( + self, + *, + distinct_id: str | None = None, + replay_ids: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + limit: int = 100, + ) -> list[ReplaySummary]: + """Discover replays for a user or hydrate explicit IDs. + + Issues exactly one Insights query against ``$mp_session_record`` + grouped on ``$mp_replay_id`` and ``$mp_replay_retention_period``, + then collapses the result into :class:`ReplaySummary` rows. A + :class:`UserWarning` fires for any replay missing + ``$mp_replay_retention_period`` — those default to 30 days per + error-messages.md §10. + + Args: + distinct_id: Filter to a single user; mutually exclusive with + ``replay_ids``. Validated by :meth:`Workspace.list_replays`. + replay_ids: Hydrate explicit IDs; mutually exclusive with + ``distinct_id``. + from_date: ISO date (YYYY-MM-DD) lower bound. When omitted (the + ``replay_ids`` hydration path used by ``_resolve_retention``), + the query falls back to a 90-day lookback (``last=90``) so + replays anywhere in the maximum retention window are + discoverable — the underlying ``Workspace.query`` default of + ``last=30`` would silently miss replays 31–90 days old, + defaulting their retention to 30 and breaking the CDN walk. + to_date: ISO date (YYYY-MM-DD) upper bound. Paired with + ``from_date``; ignored unless ``from_date`` is also set. + limit: Maximum summaries to return (default 100). + + Returns: + List of :class:`ReplaySummary`, possibly empty. + + Raises: + RuntimeError: Service was constructed without a ``query_fn``. + """ + if self._query_fn is None: + raise RuntimeError( + "ReplaysService.discover requires query_fn (typically " + "Workspace.query) at construction" + ) + + if distinct_id is not None: + where: Filter | list[Filter] = Filter.equals("$distinct_id", distinct_id) + elif replay_ids: + where = Filter.equals("$mp_replay_id", list(replay_ids)) + else: + return [] + + # Scope the discovery scan. With an explicit window the query is tight + # and precise; without one (the replay_ids hydration path), fall back to + # a 90-day lookback so replays anywhere in the maximum retention window + # are discoverable. The underlying Workspace.query default (last=30) + # would silently miss replays 31–90 days old, defaulting their retention + # to 30 and breaking the CDN walk — same fix as events_for. + date_kwargs: dict[str, Any] + if from_date is not None and to_date is not None: + date_kwargs = {"from_date": from_date, "to_date": to_date} + else: + date_kwargs = {"last": _EVENTS_DEFAULT_LOOKBACK_DAYS} + + # math="min" on the event $time property returns the earliest event + # timestamp per (replay, retention) segment as a single compact leaf — + # one value per replay, no per-second time buckets and no result-cap + # risk. The leaf is unix SECONDS; _to_unix_ms up-converts it. Note the + # property is "$time" (the reserved event-time property); plain "time" + # silently returns an empty series. + result = self._query_fn( + "$mp_session_record", + group_by=["$mp_replay_id", "$mp_replay_retention_period"], + where=where, + math="min", + math_property="$time", + mode="table", + **date_kwargs, + ) + + project_id = int(self._api.project_id) + return self._parse_summaries( + result, + project_id=project_id, + distinct_id=distinct_id, + limit=limit, + ) + + def _parse_summaries( + self, + result: Any, + *, + project_id: int, + distinct_id: str | None, + limit: int, + ) -> list[ReplaySummary]: + """Collapse a min-time Insights ``series`` into :class:`ReplaySummary` rows. + + Walks ``result.series`` — the raw nested dict the Insights API returns — + rather than the lossy ``.df`` projection. ``.df`` only flattens one + segment level and never names columns after the grouped property, so it + is unusable for the multi-key replay discovery group-by. The series + nests in group order with an ``$overall`` rollup key at every level:: + + {metric: {replay_id: {retention: {"all": min_time_seconds}}}} + + The leaf is the per-replay minimum event time in unix SECONDS (from the + ``math="min"`` / ``math_property="time"`` aggregation); + :func:`_to_unix_ms` up-converts it. Retention defaults to 30 with a + :class:`UserWarning` when the property is absent, per error-messages.md §10. + + Args: + result: Output of :func:`Workspace.query` (reads ``.series``). + project_id: Project to stamp on each summary. + distinct_id: When set, used as the ``distinct_id`` on every + summary; otherwise the parser leaves it ``None``. + limit: Hard cap on the returned list length. + + Returns: + Up to ``limit`` :class:`ReplaySummary` rows. Empty when the + query produced no replays. + """ + replay_level = _first_metric_node(getattr(result, "series", None)) + if replay_level is None: + return [] + + summaries: list[ReplaySummary] = [] + seen: set[str] = set() + for replay_id, retention_node in replay_level.items(): + if replay_id == "$overall": + continue + replay_id_str = str(replay_id) + if not replay_id_str or replay_id_str in seen: + continue + if not isinstance(retention_node, dict): + continue + + retention_days, min_time = _extract_retention_and_time( + retention_node, replay_id_str + ) + start_time = _to_unix_ms(min_time) + if start_time <= 0: + # No usable start time — can't form a valid ReplaySummary + # (positive unix-ms is a constructor invariant). + continue + + seen.add(replay_id_str) + summaries.append( + ReplaySummary( + replay_id=replay_id_str, + distinct_id=distinct_id, + project_id=project_id, + start_time=start_time, + retention_days=retention_days, + ) + ) + if len(summaries) >= limit: + break + + return summaries + + def events_for( + self, + replay_ids: list[str], + *, + event_properties: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + ) -> dict[str, list[ReplayEvent]]: + """Mixpanel events for a list of replays in one round-trip. + + Mirrors the upstream + ``analytics/backend/replays/query_utils.py::build_replay_events_request``: + queries the ``$all_events`` wildcard grouped on ``$time`` / + ``$event_name`` / ``$mp_replay_id`` (+ any caller-supplied + ``event_properties``), filters on ``$mp_replay_id IN replay_ids``, + and excludes the ``$mp_session_record`` event itself (per the + contract — these are events that happened DURING the replay + window, not the recording event). + + Args: + replay_ids: Replays to look up events for. May be empty. + event_properties: Up to 5 extra event properties to include as + group keys. + from_date: ISO date (YYYY-MM-DD) lower bound for the events scan. + When omitted, the query falls back to a 90-day lookback + (``last=90``) so events for replays anywhere in the maximum + retention window are captured — the underlying + ``Workspace.query`` default of ``last=30`` would silently miss + events for replays 31–90 days old. + to_date: ISO date (YYYY-MM-DD) upper bound. Paired with + ``from_date``; ignored unless ``from_date`` is also set. + + Returns: + Dict mapping replay_id → time-sorted :class:`ReplayEvent` list. + Replays with no events are omitted. + + Raises: + RuntimeError: Service was constructed without a ``query_fn``. + """ + if self._query_fn is None: + raise RuntimeError( + "ReplaysService.events_for requires query_fn (typically " + "Workspace.query) at construction" + ) + if not replay_ids: + return {} + + # Group on time + event_name + replay_id (+ any caller extras) so + # the result has one row per event per replay. Matches upstream's + # REPLAY_EVENT_BASE_GROUPS. + group_by: list[str] = ["$time", "$event_name", "$mp_replay_id"] + if event_properties: + group_by.extend(event_properties) + + # Two filters: limit to the requested replays AND exclude the + # recording event itself. Without the exclusion, every replay's + # event list would have an N-second-resolution duplicate of the + # recording-start event. + where = [ + Filter.equals("$mp_replay_id", list(replay_ids)), + Filter.not_equals("$event_name", "$mp_session_record"), + ] + # Scope the events scan. With an explicit window (callers that know the + # replay's time — e.g. fetch_replay) the query is tight and precise. + # Without one, fall back to a 90-day lookback so we cover the maximum + # retention window; the underlying Workspace.query default (last=30) + # would silently miss events for replays 31–90 days old. + date_kwargs: dict[str, Any] + if from_date is not None and to_date is not None: + date_kwargs = {"from_date": from_date, "to_date": to_date} + else: + date_kwargs = {"last": _EVENTS_DEFAULT_LOOKBACK_DAYS} + result = self._query_fn( + "$all_events", + group_by=group_by, + where=where, + mode="table", + **date_kwargs, + ) + + # Parse result.series directly. The .df projection only flattens one + # segment level and labels group axes generically (segment/date), so it + # silently drops every row for this 3+-key group-by. _flatten_series + # walks the nested dict in group_by order, skipping $overall rollups. + out: dict[str, list[ReplayEvent]] = {} + rows = _flatten_series(getattr(result, "series", None), group_by) + for row in rows: + replay_id = row.get("$mp_replay_id") + if replay_id is None: + continue + replay_id_str = str(replay_id) + event_time = _to_unix_seconds(row.get("$time")) + if event_time <= 0: + continue + event_name = str(row.get("$event_name", "(unknown)")) + properties: dict[str, Any] | None = None + if event_properties: + properties = { + prop: row[prop] for prop in event_properties if prop in row + } + out.setdefault(replay_id_str, []).append( + ReplayEvent( + replay_id=replay_id_str, + event_name=event_name, + event_time=event_time, + properties=properties, + ) + ) + + # Match upstream's deterministic time-ordered output per replay. + for replay_id_str in out: + out[replay_id_str].sort(key=lambda e: e.event_time) + + return out + + +def _first_metric_node(series: Any) -> dict[str, Any] | None: + """Return the first dict-valued metric node of an Insights ``series``. + + A grouped Insights response is ``{metric_name: {segment: ...}}``. Discovery + queries a single metric, so the first dict value is the segment (replay) + level. Returns ``None`` when ``series`` is absent, not a dict, or carries no + dict-valued metric. + + Args: + series: The ``result.series`` value (expected ``dict[str, Any]``). + + Returns: + The replay-level dict, or ``None``. + """ + if not isinstance(series, dict): + return None + for value in series.values(): + if isinstance(value, dict): + return value + return None + + +def _leaf_value(leaf: Any) -> Any: + """Extract the scalar from a series leaf — ``{"all": v}`` → ``v``. + + Args: + leaf: A series leaf node, normally ``{"all": value}``. + + Returns: + The ``"all"`` value when ``leaf`` is a dict; otherwise ``leaf`` itself. + """ + if isinstance(leaf, dict): + return leaf.get("all") + return leaf + + +def _extract_retention_and_time( + retention_node: dict[str, Any], replay_id: str +) -> tuple[int, Any]: + """Pull ``(retention_days, min_time)`` from a replay's retention subtree. + + The subtree maps retention windows (plus an ``$overall`` rollup) to a leaf + ``{"all": min_time_seconds}``. Returns the first standard retention window + in ``{1, 7, 30, 90}`` with its min-time leaf. When none is present (older + SDK that doesn't stamp ``$mp_replay_retention_period``), defaults to 30 + days, emits a :class:`UserWarning` naming the replay, and recovers the + min-time from any available branch (a non-standard key if one exists, else + the ``$overall`` rollup). + + Args: + retention_node: The replay's retention-level dict from ``series``. + replay_id: The replay id, used in the warning message. + + Returns: + A ``(retention_days, min_time_value)`` pair. ``min_time_value`` is the + raw leaf scalar (unix seconds) for the caller to convert; may be + ``None`` when the subtree carries no usable leaf. + """ + fallback_key: str | None = None + for key, leaf in retention_node.items(): + if key == "$overall": + continue + if fallback_key is None: + fallback_key = key + try: + window = int(key) + except (TypeError, ValueError): + continue + if window in (1, 7, 30, 90): + return window, _leaf_value(leaf) + + warnings.warn( + ( + f"replay {replay_id} is missing " + f"$mp_replay_retention_period; defaulting to 30 days. Upgrade your " + f"Mixpanel SDK to stamp this property on new recordings." + ), + UserWarning, + stacklevel=3, + ) + if fallback_key is not None: + return _DEFAULT_RETENTION_DAYS, _leaf_value(retention_node[fallback_key]) + return _DEFAULT_RETENTION_DAYS, _leaf_value(retention_node.get("$overall")) + + +def _flatten_series(series: Any, group_by: list[str]) -> list[dict[str, Any]]: + """Flatten a nested Insights ``series`` into one row dict per leaf. + + Walks the nested dict in ``group_by`` order, skipping the ``$overall`` + rollup key at every level, and emits a flat row dict for each surviving + leaf — e.g. ``{"$time": ..., "$event_name": ..., "$mp_replay_id": ..., + "count": N}``. Handles arbitrary depth, so callers that append extra group + keys (event properties) get those keys in each row automatically. + + Args: + series: The ``result.series`` nested dict (``{metric: {...}}``). + group_by: Group-by property names in request order — the nesting order + of the series. + + Returns: + One row dict per non-rollup leaf. Empty when ``series`` is empty or not + a dict. + """ + rows: list[dict[str, Any]] = [] + if not isinstance(series, dict): + return rows + for node in series.values(): + if isinstance(node, dict): + _walk_series(node, group_by, 0, {}, rows) + return rows + + +def _walk_series( + node: dict[str, Any], + group_by: list[str], + depth: int, + acc: dict[str, Any], + rows: list[dict[str, Any]], +) -> None: + """Recursively collect leaf rows from a nested series node. + + Args: + node: The current sub-dict. + group_by: Group-by property names (the nesting order). + depth: How many group levels have been consumed so far. + acc: Group key/value pairs accumulated down this branch. + rows: Output accumulator, appended in place. + + Returns: + None. Results are appended to ``rows``. + """ + if depth >= len(group_by): + rows.append({**acc, "count": _leaf_value(node)}) + return + prop = group_by[depth] + for key, child in node.items(): + if key == "$overall": + continue + if isinstance(child, dict): + _walk_series(child, group_by, depth + 1, {**acc, prop: key}, rows) + + +def _to_unix_ms(value: Any) -> int: + """Coerce a Mixpanel ``$time`` value to unix milliseconds. + + Mixpanel's ``$time`` column comes back as an ISO-8601 string, a + pandas Timestamp, or a unix seconds/ms int depending on the response + shape. Returns 0 when the value can't be parsed — callers treat that + as "skip this row". + + Args: + value: Raw cell from the result DataFrame. + + Returns: + Unix milliseconds, or 0 when the value isn't interpretable. + """ + if value is None: + return 0 + if isinstance(value, int): + return value if value > 10**12 else value * 1000 + if isinstance(value, float): + ivalue = int(value) + return ivalue if ivalue > 10**12 else ivalue * 1000 + try: + import pandas as pd # local import; pandas already a project dep + + ts = pd.Timestamp(value) + return int(ts.value // 1_000_000) + except (ValueError, TypeError, ImportError): + return 0 + + +def _to_unix_seconds(value: Any) -> int: + """Coerce a Mixpanel ``$time`` value to unix seconds. + + Mirror of :func:`_to_unix_ms` for the ``ReplayEvent.event_time`` + field (Mixpanel-native seconds). + + Args: + value: Raw cell from the result DataFrame. + + Returns: + Unix seconds, or 0 when the value isn't interpretable. + """ + ms = _to_unix_ms(value) + return ms // 1000 if ms > 0 else 0 diff --git a/src/mixpanel_headless/cli/commands/replays.py b/src/mixpanel_headless/cli/commands/replays.py new file mode 100644 index 00000000..af2c3d21 --- /dev/null +++ b/src/mixpanel_headless/cli/commands/replays.py @@ -0,0 +1,546 @@ +"""Session-replay CLI commands (044-session-replay). + +Implements ``mp replays {list,events,sign,fetch,analyze,for-user}``. The +``analyze`` and ``for-user`` commands run the vendored rrweb analyzer over +the fetched rrweb stream. + +Security: ``sign`` masks ``query_string`` by default. The +``--reveal-signed-urls`` opt-in emits a stderr warning on every +invocation per contracts/cli-commands.md §4. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated, Literal + +import typer + +from mixpanel_headless.cli.options import FormatOption, JqOption +from mixpanel_headless.cli.utils import ( + ExitCode, + console, + err_console, + get_workspace, + handle_errors, + output_result, + status_spinner, +) + +replays_app = typer.Typer( + name="replays", + help="Session replay commands.", + no_args_is_help=True, +) + + +# Stderr warning emitted every time --reveal-signed-urls is used. +_BEARER_WARNING = ( + "warning: signed URLs are bearer credentials valid for ~5 minutes. " + "Treat them like session tokens — do not paste into chat, logs, " + "or version control." +) + + +# Supported values for `mp replays for-user --include`. Anything outside this +# set is a usage error (rejected via typer.BadParameter) rather than silently +# ignored, so a typo like `--include analyse` fails loudly. +_VALID_INCLUDES = frozenset({"analyze"}) + + +# ============================================================================= +# mp replays list +# ============================================================================= + + +@replays_app.command("list") +@handle_errors +def replays_list( + ctx: typer.Context, + user: Annotated[ + str | None, + typer.Option( + "--user", + help="Mixpanel distinct_id. Mutually exclusive with --replay-id.", + ), + ] = None, + replay_id: Annotated[ + list[str] | None, + typer.Option( + "--replay-id", + help="Explicit replay ID to hydrate; repeatable. " + "Mutually exclusive with --user.", + ), + ] = None, + from_date: Annotated[ + str | None, + typer.Option("--from", help="ISO date (YYYY-MM-DD). Required with --user."), + ] = None, + to_date: Annotated[ + str | None, + typer.Option("--to", help="ISO date (YYYY-MM-DD). Required with --user."), + ] = None, + limit: Annotated[ + int, + typer.Option("--limit", help="Maximum summaries to return. Default 100."), + ] = 100, + format: FormatOption = "json", + jq_filter: JqOption = None, +) -> None: + """Discover replays for a user, or hydrate explicit IDs. + + Issues a single Insights query against ``$mp_session_record`` grouped on + ``$mp_replay_id`` and ``$mp_replay_retention_period``. Empty result is + success (exit 0), not an error. + + Args: + ctx: Typer context with global options. + user: distinct_id; mutually exclusive with --replay-id. + replay_id: Explicit replay IDs; mutually exclusive with --user. + from_date: ISO date window start (required with --user). + to_date: ISO date window end (required with --user). + limit: Maximum summaries to return. + format: Output format. + jq_filter: Optional jq expression for JSON output. + """ + workspace = get_workspace(ctx) + with status_spinner(ctx, "Listing replays..."): + summaries = workspace.list_replays( + distinct_id=user, + replay_ids=replay_id, + from_date=from_date, + to_date=to_date, + limit=limit, + ) + output_result( + ctx, + [s.to_dict() for s in summaries], + format=format, + jq_filter=jq_filter, + ) + + +# ============================================================================= +# mp replays events +# ============================================================================= + + +@replays_app.command("events") +@handle_errors +def replays_events( + ctx: typer.Context, + replay_id: Annotated[ + str, + typer.Argument(help="The replay ID to fetch Mixpanel events for."), + ], + properties: Annotated[ + str | None, + typer.Option( + "--properties", + help="Comma-separated event properties as group keys. Max 5.", + ), + ] = None, + from_date: Annotated[ + str | None, + typer.Option( + "--from", + help="ISO date (YYYY-MM-DD). Overrides the default 90-day lookback.", + ), + ] = None, + to_date: Annotated[ + str | None, + typer.Option("--to", help="ISO date (YYYY-MM-DD). Pairs with --from."), + ] = None, + format: FormatOption = "json", + jq_filter: JqOption = None, +) -> None: + """Mixpanel events that occurred during a replay's time window. + + The optional ``--properties`` flag accepts up to 5 event-property names + that become group keys; more than 5 exits with code 3 per the Insights + API cap. ``--from`` / ``--to`` narrow the scan window; when omitted the + workspace default (a 90-day lookback) applies, which can be expensive on + high-volume projects. + + Args: + ctx: Typer context with global options. + replay_id: The replay to query. + properties: Comma-separated property names (up to 5). + from_date: ISO window start; overrides the 90-day default lookback. + to_date: ISO window end; pairs with ``from_date``. + format: Output format. + jq_filter: Optional jq expression. + """ + prop_list = ( + [p.strip() for p in properties.split(",") if p.strip()] if properties else None + ) + if prop_list is not None and len(prop_list) > 5: + # Contract: contracts/error-messages.md §4 — emit the stable CLI + # wording and exit INVALID_ARGS (3) before touching the workspace, so + # the cap is reported even when no credentials are configured. + err_console.print( + f"[red]error:[/red] too many event properties — got " + f"{len(prop_list)}, max is 5 (Insights API limit).\n" + "Drop some properties or split into multiple queries." + ) + raise typer.Exit(ExitCode.INVALID_ARGS) + workspace = get_workspace(ctx) + with status_spinner(ctx, "Fetching replay events..."): + events = workspace.events_for_replay( + replay_id, + event_properties=prop_list, + from_date=from_date, + to_date=to_date, + ) + output_result( + ctx, + [e.to_dict() for e in events], + format=format, + jq_filter=jq_filter, + ) + + +# ============================================================================= +# mp replays sign +# ============================================================================= + + +@replays_app.command("sign") +@handle_errors +def replays_sign( + ctx: typer.Context, + replay_ids: Annotated[ + list[str], + typer.Argument(help="One or more replay IDs to sign."), + ], + env: Annotated[ + Literal["prod", "dev"], + typer.Option( + "--env", + help="Replay environment ('prod' or 'dev'). Default 'prod'.", + ), + ] = "prod", + reveal_signed_urls: Annotated[ + bool, + typer.Option( + "--reveal-signed-urls", + help=( + "Opt into full bearer-credential disclosure. Emits a stderr " + "warning on every invocation." + ), + ), + ] = False, + format: FormatOption = "json", + jq_filter: JqOption = None, +) -> None: + """Sign one or more replay IDs for CDN access. + + Default output masks ``query_string`` as ```` so the + bearer credential never ends up in default logs. ``--reveal-signed-urls`` + opts into the full credential AND prints a stderr warning every + invocation — meant for explicit "I am about to fetch this in a script" + use cases, not exploratory CLI work. + + Args: + ctx: Typer context. + replay_ids: Replay IDs to sign. + env: 'prod' or 'dev'. + reveal_signed_urls: When True, output the raw bearer credential + AND warn on stderr. + format: Output format. + jq_filter: Optional jq expression. + """ + workspace = get_workspace(ctx) + with status_spinner(ctx, "Signing replays..."): + signed = workspace.sign_replays(replay_ids, env=env) + + if reveal_signed_urls: + # Emit the warning EVERY invocation — even when stdout is piped to a + # file; the credential is sensitive regardless of pipeline shape. + err_console.print( + _BEARER_WARNING, markup=False, highlight=False, soft_wrap=True + ) + # to_dict() includes the full credential plus the documented _warning + # key so downstream serializers can surface the risk. + payload = [s.to_dict() for s in signed] + else: + # Default: use the masking that SignedReplay.__repr__ provides, + # serialized to a flat dict with redacted query_string + expires_at. + payload = [ + { + "replay_id": s.replay_id, + "url": s.url, + "query_string": f"", + "env": s.env, + "signed_at": s.signed_at, + "expires_at": s.expires_at, + } + for s in signed + ] + + output_result(ctx, payload, format=format, jq_filter=jq_filter) + + +# ============================================================================= +# mp replays fetch +# ============================================================================= + + +@replays_app.command("fetch") +@handle_errors +def replays_fetch( + ctx: typer.Context, + replay_id: Annotated[ + str, + typer.Argument(help="The replay ID to fetch."), + ], + output: Annotated[ + Path | None, + typer.Option( + "-o", + "--output", + help=( + "Write rrweb events as a JSON array to this file. When " + "omitted, prints a one-line summary to stdout." + ), + ), + ] = None, + env: Annotated[ + Literal["prod", "dev"], + typer.Option("--env", help="Replay environment ('prod' or 'dev')."), + ] = "prod", + include_events: Annotated[ + bool, + typer.Option( + "--include-events", + help="Trigger the Mixpanel-events join (populates mixpanel_events).", + ), + ] = False, + max_files: Annotated[ + int, + typer.Option("--max-files", help="Hard upper bound on CDN walk."), + ] = 500, +) -> None: + """Pull raw rrweb bytes for a single replay. + + With ``-o file.json`` the output is a timestamp-sorted JSON array + directly compatible with the rrweb JS player. Without ``-o`` the + command prints a one-line summary (event count, duration, retention) + to stdout. + + Args: + ctx: Typer context. + replay_id: The replay to fetch. + output: Optional path to write the rrweb JSON array. + env: 'prod' or 'dev'. + include_events: Whether to fire the Mixpanel-events join. + max_files: Upper bound on the CDN walk. + """ + workspace = get_workspace(ctx) + with status_spinner(ctx, "Fetching replay..."): + replay = workspace.fetch_replay( + replay_id, + env=env, + max_files=max_files, + include_mixpanel_events=include_events, + ) + + if output is not None: + # Player-compatible: timestamp-sorted array of raw rrweb events. + output.write_text(json.dumps(replay.to_rrweb_player_json())) + return + + duration_minutes = int(replay.duration_seconds // 60) + duration_seconds = int(replay.duration_seconds % 60) + console.print( + f"fetched {replay.replay_id} — {len(replay.rrweb_events)} events, " + f"{duration_minutes}m {duration_seconds:02d}s, " + f"{replay.retention_days}-day retention", + markup=False, + highlight=False, + soft_wrap=True, + ) + + +# ============================================================================= +# mp replays analyze +# ============================================================================= + + +@replays_app.command("analyze") +@handle_errors +def replays_analyze( + ctx: typer.Context, + replay_id: Annotated[ + str, + typer.Argument(help="The replay ID to analyze."), + ], + format: Annotated[ + str, + typer.Option( + "--format", + help="Output format. 'plain' = markdown timeline; 'json' = action list.", + ), + ] = "plain", +) -> None: + """Render an analyzer-produced markdown timeline for a single replay. + + Default output is the markdown timeline (suitable for stdout or LLM + consumption). With ``--format json`` the command emits the normalized + action list as a JSON array. + + Args: + ctx: Typer context. + replay_id: The replay to analyze. + format: 'plain' (default markdown) or 'json' (action list). + """ + workspace = get_workspace(ctx) + if format == "json": + # The JSON path needs the structured action list, so fetch the full + # Replay; analyze_replay only returns the rendered markdown. + with status_spinner(ctx, "Analyzing replay..."): + replay = workspace.fetch_replay(replay_id) + console.print( + json.dumps([a.to_dict() for a in replay.actions], indent=2), + markup=False, + highlight=False, + soft_wrap=True, + ) + else: + with status_spinner(ctx, "Analyzing replay..."): + markdown = workspace.analyze_replay(replay_id) + console.print(markdown, markup=False, highlight=False, soft_wrap=True) + + +# ============================================================================= +# mp replays for-user +# ============================================================================= + + +@replays_app.command("for-user") +@handle_errors +def replays_for_user( + ctx: typer.Context, + user: Annotated[ + str, + typer.Argument(help="Mixpanel distinct_id to fetch replays for."), + ], + from_date: Annotated[ + str, + typer.Option("--from", help="ISO date (YYYY-MM-DD)."), + ], + to_date: Annotated[ + str, + typer.Option("--to", help="ISO date (YYYY-MM-DD)."), + ], + include: Annotated[ + list[str] | None, + typer.Option( + "--include", + help="Extras to fetch; repeatable. Only 'analyze' is supported.", + ), + ] = None, + mixpanel_events: Annotated[ + bool, + typer.Option( + "--mixpanel-events/--no-mixpanel-events", + help="Include the Mixpanel event stream alongside actions. Default on.", + ), + ] = True, + out_dir: Annotated[ + Path | None, + typer.Option( + "--out-dir", + help=( + "Directory to write per-replay markdown + index.json. " + "When omitted, markdown summaries are concatenated to stdout." + ), + ), + ] = None, + limit: Annotated[ + int, + typer.Option("--limit", help="Maximum replays. Default 100."), + ] = 100, +) -> None: + """Discovery + fetch + analyze in one command. + + Args: + ctx: Typer context. + user: Mixpanel distinct_id. + from_date: ISO date window start. + to_date: ISO date window end. + include: Repeatable 'analyze' opt-in (emit per-replay markdown). + Only 'analyze' is supported; any other value is rejected. + mixpanel_events: Include the Mixpanel event stream alongside actions. + Defaults to True, matching Workspace.replays_for_user. + out_dir: Directory to write per-replay outputs (+ index.json). + limit: Maximum replays. + + Raises: + typer.BadParameter: ``--include`` was given a value other than + 'analyze'. + """ + include_set = set(include or []) + unsupported = include_set - _VALID_INCLUDES + if unsupported: + raise typer.BadParameter( + f"--include accepts only {sorted(_VALID_INCLUDES)}; " + f"got unsupported value(s): {sorted(unsupported)}" + ) + workspace = get_workspace(ctx) + with status_spinner(ctx, "Fetching replay bundle..."): + bundle = workspace.replays_for_user( + user, + from_date=from_date, + to_date=to_date, + limit=limit, + include_mixpanel_events=mixpanel_events, + ) + if not bundle.replays: + console.print( + f"no replays found for {user} in {from_date}..{to_date}", + markup=False, + highlight=False, + soft_wrap=True, + ) + return + + if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + if "analyze" in include_set: + for replay in bundle.replays: + (out_dir / f"{replay.replay_id}-summary.md").write_text( + replay.summary_markdown + ) + # index.json mirrors bundle.sessions_df for downstream consumers. + index_path = out_dir / "index.json" + index_path.write_text(bundle.sessions_df.to_json(orient="records")) + df = bundle.sessions_df + total_actions = int(df["n_actions"].sum()) if not df.empty else 0 + total_clicks = int(df["n_clicks"].sum()) if not df.empty else 0 + total_errors = int(df["n_errors"].sum()) if not df.empty else 0 + console.print( + f"wrote {len(bundle.replays)} replays to {out_dir}/\n" + f"total: {total_actions} actions, {total_clicks} clicks, " + f"{total_errors} errors", + markup=False, + highlight=False, + soft_wrap=True, + ) + return + + # Stdout fall-through: concatenated markdown summaries. + if "analyze" in include_set: + for replay in bundle.replays: + console.print( + replay.summary_markdown, + markup=False, + highlight=False, + soft_wrap=True, + ) + console.print("\n---\n", markup=False, highlight=False, soft_wrap=True) + else: + console.print( + bundle.summary_markdown, markup=False, highlight=False, soft_wrap=True + ) diff --git a/src/mixpanel_headless/cli/main.py b/src/mixpanel_headless/cli/main.py index 2f0134b7..adcfb952 100644 --- a/src/mixpanel_headless/cli/main.py +++ b/src/mixpanel_headless/cli/main.py @@ -187,6 +187,7 @@ def _register_commands() -> None: from mixpanel_headless.cli.commands.lookup_tables import lookup_tables_app from mixpanel_headless.cli.commands.project import project_app from mixpanel_headless.cli.commands.query import query_app + from mixpanel_headless.cli.commands.replays import replays_app from mixpanel_headless.cli.commands.reports import reports_app from mixpanel_headless.cli.commands.schemas import schemas_app from mixpanel_headless.cli.commands.session import session_app @@ -242,6 +243,11 @@ def _register_commands() -> None: name="business-context", help="Read and write project / organization business context.", ) + app.add_typer( + replays_app, + name="replays", + help="Session replay discovery, signing, and fetch.", + ) # Register commands when module is imported diff --git a/src/mixpanel_headless/cli/utils.py b/src/mixpanel_headless/cli/utils.py index f8dcbab5..e59f21de 100644 --- a/src/mixpanel_headless/cli/utils.py +++ b/src/mixpanel_headless/cli/utils.py @@ -41,7 +41,11 @@ RateLimitError, RegionProbeError, RegionProbeNetworkError, + ReplayNotFoundError, ServerError, + SessionReplayAccessError, + SignedURLExpiredError, + UnsupportedReplayFormatError, WorkspaceScopeError, ) @@ -350,6 +354,54 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: f"[red]Business context too long:[/red] {rich_escape(e.message)}" ) raise typer.Exit(ExitCode.INVALID_ARGS) from None + except SessionReplayAccessError as e: + # 044 — sensitive-data 403 maps to AUTH_ERROR (2) per + # contracts/cli-commands.md §8. Catch BEFORE the generic + # MixpanelHeadlessError so the exit code lands on 2 instead of 1. + project_id = e.details.get("project_id") + err_console.print( + f"[red]error:[/red] sensitive replay data — project " + f"{rich_escape(str(project_id))} has SESSION_RECORDING_SENSITIVE_DATA" + f" enabled and your account lacks access. Contact the project" + f" owner to grant the 'sensitive_data_replay' permission, or" + f" use a service account that has it." + ) + raise typer.Exit(ExitCode.AUTH_ERROR) from None + except ReplayNotFoundError as e: + # 044 — first-file 404 on the CDN walk maps to NOT_FOUND (4) + # per contracts/cli-commands.md §8. + replay_id = e.details.get("replay_id") + retention_days = e.details.get("retention_days", 30) + err_console.print( + f"[red]error:[/red] replay {rich_escape(str(replay_id))} not" + f" found — may have aged out of retention ({retention_days}" + f" days), never been recorded, or been deleted." + ) + raise typer.Exit(ExitCode.NOT_FOUND) from None + except SignedURLExpiredError: + # 044 — signed-URL 5-minute TTL elapsed and re-signing was + # disabled. Emit the stable wording from + # contracts/error-messages.md §2 and exit GENERAL_ERROR (1). + # Caught BEFORE the generic MixpanelHeadlessError so the canonical + # CLI message is shown instead of the longer Python message. + err_console.print( + "[red]error:[/red] signed URL expired (5-minute TTL)" + " — re-run the command" + ) + raise typer.Exit(ExitCode.GENERAL_ERROR) from None + except UnsupportedReplayFormatError as e: + # 044 — replay bytes aren't rrweb (mobile / non-web format). Maps to + # GENERAL_ERROR (1) per contracts/error-messages.md §9. Caught BEFORE + # the generic MixpanelHeadlessError so the curated one-liner is shown + # instead of a leaked traceback (it used to be a builtin + # NotImplementedError that no handler caught). + replay_id = e.details.get("replay_id") + err_console.print( + f"[red]error:[/red] replay {rich_escape(str(replay_id))} appears" + f" to be a mobile session (non-rrweb format) — not yet supported" + f" by mixpanel-headless." + ) + raise typer.Exit(ExitCode.GENERAL_ERROR) from None except MixpanelHeadlessError as e: err_console.print(f"[red]Error:[/red] {rich_escape(e.message)}") raise typer.Exit(ExitCode.GENERAL_ERROR) from None diff --git a/src/mixpanel_headless/exceptions.py b/src/mixpanel_headless/exceptions.py index e1030bbc..3f5c6a07 100644 --- a/src/mixpanel_headless/exceptions.py +++ b/src/mixpanel_headless/exceptions.py @@ -1297,3 +1297,182 @@ def error_count(self) -> int: def warning_count(self) -> int: """Number of severity="warning" items.""" return self._warning_count + + +# ============================================================================= +# Session-Replay Exceptions (044-session-replay) +# ============================================================================= + + +class SessionReplayError(APIError): + """Base class for session-replay-specific failures. + + Subclasses cover the three replay-specific failure modes: + :class:`SessionReplayAccessError` (sensitive-data permission denied), + :class:`SignedURLExpiredError` (5-minute signed-URL TTL elapsed), and + :class:`ReplayNotFoundError` (CDN walker found no bytes for the + replay). All carry the standard :class:`APIError` HTTP context + (``status_code``, ``response_body``, ``request_url``, etc.) plus a + replay-specific ``details`` dict that the subclass merges in on top + (``replay_id``, ``project_id``, ``flag``, ``retention_days``, …). + + Catch this base class to handle any replay failure uniformly: + + Example: + ```python + try: + replay = ws.fetch_replay("r-19221") + except SessionReplayError as exc: + log.warning("replay fetch failed: %s", exc.to_dict()) + ``` + + Because :class:`SessionReplayError` is an :class:`APIError`, existing + ``except APIError:`` handlers — including the CLI ``handle_errors`` + decorator that maps HTTP failures to exit codes — continue to catch + these without modification. + """ + + _DEFAULT_CODE = "SESSION_REPLAY_ERROR" + _DEFAULT_STATUS: int = 500 + + def __init__( + self, + message: str, + *, + details: dict[str, Any] | None = None, + status_code: int | None = None, + response_body: str | dict[str, Any] | None = None, + request_method: str | None = None, + request_url: str | None = None, + request_params: dict[str, Any] | None = None, + request_body: dict[str, Any] | None = None, + code: str | None = None, + ) -> None: + """Initialize SessionReplayError. + + Args: + message: Human-readable error message; see error-messages.md for + the catalog of stable wording per subclass. + details: Replay-specific structured context (``replay_id``, + ``project_id``, ``flag``, ``retention_days``, + ``cdn_url_prefix``, ``signed_at``, ``expired_at`` — + whichever keys the subclass documents). Merged into the + base :class:`APIError` ``details`` dict so consumers see + both HTTP context and replay context in one place. + status_code: HTTP status that triggered the error. Defaults + to the subclass's ``_DEFAULT_STATUS`` (403 for access / + expiry, 404 for not-found, 500 for the base class). + response_body: Raw response body for debugging. + request_method: HTTP method (GET, POST, …). + request_url: Full request URL. + request_params: Query parameters sent on the failing request. + request_body: Request body sent on the failing request. + code: Machine-readable error code. Defaults to the subclass's + ``_DEFAULT_CODE``. + """ + super().__init__( + message, + status_code=status_code + if status_code is not None + else self._DEFAULT_STATUS, + response_body=response_body, + request_method=request_method, + request_url=request_url, + request_params=request_params, + request_body=request_body, + code=code if code is not None else self._DEFAULT_CODE, + ) + if details: + self._details.update(details) + + +class SessionReplayAccessError(SessionReplayError): + """Project has SESSION_RECORDING_SENSITIVE_DATA enabled and caller lacks access. + + Raised when the bulk-sign endpoint returns 403 with a body that + mentions the ``SESSION_RECORDING_SENSITIVE_DATA`` project flag. The + project owner can grant the ``sensitive_data_replay`` permission to + unblock the caller; a service account with that permission works too. + + Details: + project_id (int): The project that gated the call. + flag (str): Always ``"SESSION_RECORDING_SENSITIVE_DATA"``. + permission_required (str): Always ``"sensitive_data_replay"``. + + See error-messages.md §1 for the canonical message wording. + """ + + _DEFAULT_CODE = "SESSION_REPLAY_ACCESS_ERROR" + _DEFAULT_STATUS = 403 + + +class SignedURLExpiredError(SessionReplayError): + """Signed CDN URL passed to a fetch has expired (5-minute TTL). + + Raised when a CDN fetch returns 403 with an expiration body AND the + caller opted out of automatic re-signing (``stream_replay`` with + ``re_sign_on_expiry=False``). Re-sign via :meth:`Workspace.sign_replay` + and retry, or pass ``re_sign_on_expiry=True`` (the default) to let the + library re-sign transparently. + + Details: + replay_id (str): The replay whose URL expired. + signed_at (float): Unix seconds when the original URL was signed. + expired_at (float): Unix seconds when the URL expired + (typically ``signed_at + 300``). + + See error-messages.md §2 for the canonical message wording. + """ + + _DEFAULT_CODE = "SIGNED_URL_EXPIRED" + _DEFAULT_STATUS = 403 + + +class ReplayNotFoundError(SessionReplayError): + """No CDN bytes found for a requested replay. + + Raised when the CDN walker hits a 404 on the very first file + (``0000-N.json``). The replay either aged out of its retention + window, was never recorded, or has been deleted. Mid-walk 404s are + treated as the end-of-replay sentinel and do NOT raise. + + Details: + replay_id (str): The replay that returned no bytes. + retention_days (int): The retention window that was assumed + (1, 7, 30, or 90). + cdn_url_prefix (str): The CDN prefix that was walked. + + See error-messages.md §3 for the canonical message wording. + """ + + _DEFAULT_CODE = "REPLAY_NOT_FOUND" + _DEFAULT_STATUS = 404 + + +class UnsupportedReplayFormatError(SessionReplayError): + """Replay bytes are not in rrweb format (mobile or other non-web recording). + + Raised by the CDN walker when the first event of a recording lacks the + standard rrweb keys (``type`` / ``data`` / ``timestamp``). Mobile session + replays (iOS / Android) use a different on-disk format that the rrweb + analyzer cannot interpret. Discovery still works because + ``$mp_session_record`` / ``$mp_replay_id`` are platform-agnostic, but the + bytes and analyzer layers are web-only. + + This is a typed :class:`SessionReplayError` (not the builtin + ``NotImplementedError`` used in earlier cuts) so callers can branch on it + and the CLI ``handle_errors`` decorator maps it to a clean message and exit + code instead of surfacing an uncaught traceback. + + Details: + replay_id (str): The replay whose bytes were not rrweb-shaped. + format (str): The detected shape — always ``"non-rrweb"``. + + The default ``status_code`` is 501 (Not Implemented): no HTTP request + failed, the format simply isn't supported yet. + + See error-messages.md §9 for the canonical message wording. + """ + + _DEFAULT_CODE = "UNSUPPORTED_REPLAY_FORMAT" + _DEFAULT_STATUS = 501 diff --git a/src/mixpanel_headless/replay_labels.py b/src/mixpanel_headless/replay_labels.py new file mode 100644 index 00000000..746bfd78 --- /dev/null +++ b/src/mixpanel_headless/replay_labels.py @@ -0,0 +1,145 @@ +"""Activity labels for the rrweb action stream (044-session-replay). + +A label is the grouping key for the path / click / transition +aggregations on :class:`ReplayBundle`. Stable labels are the precondition +for any cross-session analysis: two ``click on button "Sign in"`` events +from different replays must produce the same label string, or the +downstream aggregations fragment. + +This module ships three policies: + +- :func:`default_label_fn` — the canonical ``"{action}:{tag}@{url}"`` + shape. URL normalization strips query strings and replaces numeric path + segments with ``:id`` so ``/users/12345/profile`` and + ``/users/67890/profile`` collapse into a single activity. +- :func:`selector_label_fn` — factory for projects with stable + ``data-testid`` (or equivalent) attributes; falls back to the default + when the attribute is absent. +- :func:`url_normalizer` — exposed as a standalone helper for callers + who want to apply the same normalization outside the label-fn path. + +These helpers are part of the public API. Import them from the top-level +package (``from mixpanel_headless import default_label_fn``) or from this +module directly; do not reach into ``mixpanel_headless._internal``. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable + +from mixpanel_headless.types import UserAction + +# Numeric path segments — IDs, version numbers, year/month/day pieces — are +# replaced with ``:id`` so URLs collapse across users / instances. Hex IDs +# (UUIDs, short SHAs) also count as IDs; pure-text segments survive. +_NUMERIC_OR_HEX = re.compile(r"^([0-9]+|[0-9a-f]{8,}|[0-9a-fA-F\-]{8,})$") + + +def url_normalizer(url: str) -> str: + """Normalize a URL into a path template suitable for label aggregation. + + Strips the query string and replaces numeric / hex path segments with + ``:id``. The host portion is preserved when present (otherwise the + function treats the input as a bare path). + + Args: + url: A URL, absolute or relative. + + Returns: + The normalized path template. Example: + ``/users/12345/profile?ref=x`` → ``/users/:id/profile``. + + Example: + ```python + url_normalizer("/users/12345/profile?ref=x") + # '/users/:id/profile' + url_normalizer("https://app.example.com/orders/abc12345-de00") + # 'https://app.example.com/orders/:id' + ``` + """ + if not url: + return url + # Split host from path. Naive splitter — anything before the first + # single slash after a possible scheme is the host. + host_prefix = "" + rest = url + if "://" in url: + scheme, after = url.split("://", 1) + if "/" in after: + host, path = after.split("/", 1) + host_prefix = f"{scheme}://{host}" + rest = "/" + path + else: + return f"{scheme}://{after}" + # Drop the query string. + if "?" in rest: + rest = rest.split("?", 1)[0] + # Walk path segments and replace numeric / hex ones. + parts = rest.split("/") + normalized = [ + ":id" if part and _NUMERIC_OR_HEX.match(part) else part for part in parts + ] + return host_prefix + "/".join(normalized) + + +def default_label_fn(action: UserAction) -> str: + """Canonical activity label: ``"{action}:{tag}@{normalized_url}"``. + + ``tag`` comes from ``action.target_desc`` (the analyzer's best + description of the element — e.g. ``'button "Sign in"'``). The URL + is normalized through :func:`url_normalizer` so analogous actions on + parameterized pages aggregate cleanly. + + Args: + action: A :class:`UserAction` from a replay's analyzer output. + + Returns: + The activity label string. + + Example: + ```python + action = UserAction(timestamp=1, action="click", + target_node_id=42, target_desc='button "Sign in"', + url="/users/12345/profile?ref=x", metadata={}) + default_label_fn(action) + # 'click:button "Sign in"@/users/:id/profile' + ``` + """ + tag = action.target_desc or "(unknown)" + url = url_normalizer(action.url) if action.url else "(no-url)" + return f"{action.action}:{tag}@{url}" + + +def selector_label_fn(attr: str = "data-testid") -> Callable[[UserAction], str]: + """Build a label-fn that prefers a stable selector attribute when present. + + For instrumented apps, ``data-testid`` (or your project's equivalent) + is the most stable activity identifier — it survives DOM refactors, + locale changes, and CSS tweaks. The returned label-fn looks for the + requested attribute in ``action.metadata`` and uses it as the label + body when present; otherwise it falls back to :func:`default_label_fn`. + + Args: + attr: The metadata key to consult. Default ``"data-testid"``. + + Returns: + A callable ``(UserAction) -> str`` suitable as a ``label_fn`` override + for :meth:`ReplayBundle.find_pattern`. + + Example: + ```python + label_fn = selector_label_fn("data-testid") + bundle.find_pattern(["click:button@/", ...], label_fn=label_fn) + ``` + """ + + def _label(action: UserAction) -> str: + """Use the configured selector attribute when present; fall back otherwise.""" + candidate = action.metadata.get(attr) if action.metadata else None + if candidate: + url = url_normalizer(action.url) if action.url else "(no-url)" + return f"{action.action}:{candidate}@{url}" + return default_label_fn(action) + + return _label diff --git a/src/mixpanel_headless/types.py b/src/mixpanel_headless/types.py index a1be3c83..dce50be4 100644 --- a/src/mixpanel_headless/types.py +++ b/src/mixpanel_headless/types.py @@ -20,7 +20,9 @@ import json import math import re +import time import warnings +from collections.abc import Callable from dataclasses import dataclass, field from datetime import date as dt_date from datetime import datetime @@ -12264,3 +12266,1406 @@ class OAuthLoginResult(BaseModel): client_path: Path """Where the DCR client info was persisted (``~/.mp/accounts/{name}/client.json``).""" + + +# ============================================================================= +# Session Replay Types (044-session-replay) +# ============================================================================= +# +# Six in-memory dataclasses backing the session-replay surface: +# ``ReplaySummary``, ``SignedReplay``, ``ReplayEvent``, ``UserAction``, +# ``Replay``, and ``ReplayBundle``. The rrweb analyzer populates +# ``Replay.actions`` (and the ``UserAction`` records) on fetch. +# See ``specs/044-session-replay/data-model.md`` for the full schema and +# state-transition diagram, and ``contracts/python-api.md`` for the +# canonical method signatures these types appear in. + + +_REPLAY_ACTION_LITERAL = Literal[ + "click", + "input", + "scroll", + "navigate", + "select", + "console_error", + "viewport_resize", + "touch_start", + "media_interaction", +] +"""Closed set of normalized action labels emitted by the rrweb analyzer. + +Locked here so callers can write exhaustive ``match`` statements and so +mypy --strict catches typos in label-fn implementations. New action types +require a minor version bump and a CHANGELOG entry. +""" + + +_ALLOWED_RETENTION_DAYS = frozenset({1, 7, 30, 90}) +"""Allowed Mixpanel session-replay retention windows. + +Mixpanel only stores recordings at one of these four retention windows; +``ReplaySummary`` and ``Replay`` reject any other value at construction. +""" + + +@dataclass(frozen=True) +class ReplaySummary(ResultWithDataFrame): + """Discovery handle for a single replay (data-model §2.1). + + Returned by :meth:`Workspace.list_replays`. Holds the minimum info + needed to decide whether to materialize the full recording: replay + ID, distinct ID, project, start time, retention window. Use + :meth:`Workspace.fetch_replay` to upgrade a summary into a full + :class:`Replay` with the rrweb bytes pulled and parsed. + + Attributes: + replay_id: Mixpanel replay identifier; non-empty. + distinct_id: Mixpanel user identifier; ``None`` for anonymous sessions. + project_id: Owning project; positive int. + start_time: Unix ms timestamp from the ``$mp_session_record`` event. + retention_days: Days of CDN retention; one of ``{1, 7, 30, 90}``. + + Example: + ```python + for s in ws.list_replays(distinct_id="u-42", from_date="2026-05-20", + to_date="2026-05-27"): + replay = ws.fetch_replay(s.replay_id, retention_days=s.retention_days) + print(replay.duration_seconds) + ``` + """ + + replay_id: str + distinct_id: str | None + project_id: int + start_time: int + retention_days: int + + def __post_init__(self) -> None: + """Validate per data-model §2.1. + + Raises: + ValueError: ``replay_id`` is empty, ``project_id`` is non-positive, + ``start_time`` is non-positive, or ``retention_days`` is + outside ``{1, 7, 30, 90}``. + """ + if not self.replay_id: + raise ValueError("replay_id must be non-empty") + if self.project_id <= 0: + raise ValueError(f"project_id must be positive; got {self.project_id}") + if self.start_time <= 0: + raise ValueError( + f"start_time must be a positive unix ms timestamp; got " + f"{self.start_time}" + ) + if self.retention_days not in _ALLOWED_RETENTION_DAYS: + raise ValueError( + f"retention_days must be in {{1, 7, 30, 90}}; got {self.retention_days}" + ) + + @property + def df(self) -> pd.DataFrame: + """Single-row DataFrame projection of this summary. + + Returns: + DataFrame with columns ``replay_id``, ``distinct_id``, + ``project_id``, ``start_time``, ``retention_days`` — one row. + Cached on first access. + """ + if self._df_cache is not None: + return self._df_cache + result = pd.DataFrame( + [ + { + "replay_id": self.replay_id, + "distinct_id": self.distinct_id, + "project_id": self.project_id, + "start_time": self.start_time, + "retention_days": self.retention_days, + } + ] + ) + object.__setattr__(self, "_df_cache", result) + return result + + def to_dict(self) -> dict[str, Any]: + """JSON-serializable representation. + + Returns: + Dict with the five summary fields. + """ + return { + "replay_id": self.replay_id, + "distinct_id": self.distinct_id, + "project_id": self.project_id, + "start_time": self.start_time, + "retention_days": self.retention_days, + } + + +@dataclass(frozen=True) +class SignedReplay: + """Signed CDN access handle (data-model §2.2). + + SECURITY: ``query_string`` is a bearer credential valid for ~5 minutes. + :meth:`__repr__` and :meth:`__str__` mask it so default Python logging + cannot leak the signature. :meth:`to_dict` IS the documented escape + hatch — callers that need the raw credential opt in explicitly and + receive an extra ``_warning`` key as a reminder. + + Attributes: + replay_id: Mixpanel replay identifier. + url: CDN URL prefix with trailing slash; CDN files live at + ``f"{url}{N:04d}-{retention_days}.json?{query_string}"``. + query_string: The signed bearer credential (NOT logged by default). + env: Replay environment, ``"prod"`` or ``"dev"``. + signed_at: Unix seconds when the URL was signed; ``expires_at`` + arithmetic uses ``signed_at + 300``. + + Example: + ```python + signed = ws.sign_replay("r-19221") + if not signed.is_expired: + url = f"{signed.url}0000-30.json?{signed.query_string}" + ``` + """ + + replay_id: str + url: str + query_string: str + env: Literal["prod", "dev"] + signed_at: float + + def __post_init__(self) -> None: + """Validate per data-model §2.2. + + Raises: + ValueError: ``url`` lacks a trailing slash, ``query_string`` + is empty, ``env`` is not ``"prod"`` or ``"dev"``, or + ``signed_at`` is negative. + """ + if not self.url.endswith("/"): + raise ValueError( + f"url must end with '/' for CDN-path concatenation; got {self.url!r}" + ) + if not self.query_string: + raise ValueError("query_string must be non-empty") + if self.env not in ("prod", "dev"): + raise ValueError(f"env must be 'prod' or 'dev'; got {self.env!r}") + if self.signed_at < 0: + raise ValueError(f"signed_at must be non-negative; got {self.signed_at}") + + @property + def expires_at(self) -> float: + """Approximate expiration timestamp (``signed_at + 300`` seconds). + + Returns: + Unix seconds at which the signed URL is considered expired. + """ + return self.signed_at + 300 + + @property + def is_expired(self) -> bool: + """Whether the URL has crossed the 5-minute TTL boundary. + + Returns: + True when ``time.time() >= expires_at``. + """ + return time.time() >= self.expires_at + + def to_dict(self) -> dict[str, Any]: + """Full serialization including the bearer credential. + + WARNING: includes the full ``query_string``. The returned dict + carries a top-level ``_warning`` key noting the bearer nature so + downstream serializers can surface the risk. + + Returns: + Dict with ``_warning`` plus the five visible fields. + """ + return { + "_warning": ("query_string is a bearer credential valid for ~5 minutes"), + "replay_id": self.replay_id, + "url": self.url, + "query_string": self.query_string, + "env": self.env, + "signed_at": self.signed_at, + } + + def __repr__(self) -> str: + """Masked representation — never leaks ``query_string``. + + Returns: + String of the form + ``SignedReplay(replay_id='r-19221', url='...', query_string='', env='prod', signed_at=...)``. + """ + masked = f"" + return ( + f"SignedReplay(replay_id={self.replay_id!r}, url={self.url!r}, " + f"query_string={masked!r}, env={self.env!r}, " + f"signed_at={self.signed_at!r})" + ) + + def __str__(self) -> str: + """Delegate to :meth:`__repr__` so f-strings and ``print()`` stay safe.""" + return self.__repr__() + + +@dataclass(frozen=True) +class UserAction: + """Normalized user action extracted from rrweb events (data-model §2.3). + + Produced by the rrweb analyzer and exposed via :attr:`Replay.actions` — + the atomic unit the :class:`ReplayBundle` aggregations operate over. + + Attributes: + timestamp: Unix ms timestamp of the action. + action: One of the closed-set action labels (``click``, ``input``, + ``scroll``, ``navigate``, ``select``, ``console_error``, + ``viewport_resize``, ``touch_start``, ``media_interaction``). + target_node_id: rrweb DOM node ID of the action target, if any. + target_desc: Human-readable target description (e.g. + ``'button "Sign in"'``); non-empty. + url: Active page URL when the action happened, if known. + metadata: Action-specific extras (text_length, is_checked, …). + description: Full human-readable phrase for the markdown timeline + (e.g. ``'Clicked button "Sign in"'``, ``'Scrolled'``, + ``'Console error: …'``). The analyzer populates it; renderers + fall back to ``target_desc`` when it is empty. + """ + + timestamp: int + action: _REPLAY_ACTION_LITERAL + target_node_id: int | None + target_desc: str + url: str | None + metadata: dict[str, Any] = field(default_factory=dict) + description: str = "" + + def __post_init__(self) -> None: + """Validate per data-model §2.3. + + Raises: + ValueError: ``timestamp`` is non-positive or ``target_desc`` + is empty. + """ + if self.timestamp <= 0: + raise ValueError( + f"timestamp must be a positive unix ms timestamp; got {self.timestamp}" + ) + if not self.target_desc: + raise ValueError("target_desc must be non-empty") + + def to_dict(self) -> dict[str, Any]: + """JSON-serializable representation. + + Returns: + Dict with the seven visible fields. + """ + return { + "timestamp": self.timestamp, + "action": self.action, + "target_node_id": self.target_node_id, + "target_desc": self.target_desc, + "url": self.url, + "metadata": dict(self.metadata), + "description": self.description, + } + + +@dataclass(frozen=True) +class ReplayEvent(ResultWithDataFrame): + """Mixpanel event that occurred during a replay's time window + (data-model §2.4). + + Optional enrichment on :class:`Replay` (via + ``fetch_replay(include_mixpanel_events=True)``) and the primary return + type of :meth:`Workspace.events_for_replay`. + + Attributes: + replay_id: Owning replay ID; non-empty. + event_name: Mixpanel event name; non-empty. + event_time: Unix SECONDS timestamp (Mixpanel native), not ms. + properties: Selected event properties; ``None`` when the caller + skipped enrichment via ``event_properties=None``. + """ + + replay_id: str + event_name: str + event_time: int + properties: dict[str, Any] | None = None + + def __post_init__(self) -> None: + """Validate per data-model §2.4. + + Raises: + ValueError: ``replay_id`` or ``event_name`` is empty, or + ``event_time`` is non-positive. + """ + if not self.replay_id: + raise ValueError("replay_id must be non-empty") + if not self.event_name: + raise ValueError("event_name must be non-empty") + if self.event_time <= 0: + raise ValueError( + f"event_time must be a positive unix seconds timestamp; got " + f"{self.event_time}" + ) + + @property + def df(self) -> pd.DataFrame: + """Single-row DataFrame projection of this event. + + Returns: + DataFrame with columns ``replay_id``, ``event_name``, + ``event_time``, ``properties`` — one row. Cached on first access. + """ + if self._df_cache is not None: + return self._df_cache + result = pd.DataFrame( + [ + { + "replay_id": self.replay_id, + "event_name": self.event_name, + "event_time": self.event_time, + "properties": self.properties, + } + ] + ) + object.__setattr__(self, "_df_cache", result) + return result + + def to_dict(self) -> dict[str, Any]: + """JSON-serializable representation. + + Returns: + Dict with the four visible fields. + """ + return { + "replay_id": self.replay_id, + "event_name": self.event_name, + "event_time": self.event_time, + "properties": ( + copy.deepcopy(self.properties) if self.properties is not None else None + ), + } + + +# rrweb event-type discriminators — exposed as named constants so the +# Replay projection code stays self-documenting. The rrweb analyzer carries +# the full set; the projection layer only needs the four below. +_RRWEB_TYPE_FULL_SNAPSHOT = 2 +_RRWEB_TYPE_INCREMENTAL_SNAPSHOT = 3 +_RRWEB_TYPE_META = 4 + +# IncrementalSnapshot.data.source discriminators relevant to the projection +# layer. The rrweb analyzer handles the rest. +_RRWEB_SOURCE_MOUSE_INTERACTION = 2 + + +def _rrweb_event_row(event: dict[str, Any]) -> dict[str, Any]: + """Project one rrweb event into the ``events_df`` row shape. + + Pulls the discriminators the analyzer would care about — ``source`` + for IncrementalSnapshot events, ``type`` of MouseInteraction events + (mapped to a friendly string under ``mouse_type``), DOM node ID under + ``target_node_id``, and the page ``url`` for Meta events. + + Args: + event: Raw rrweb event dict (``type``, ``data``, ``timestamp``). + + Returns: + Dict with the seven ``events_df`` columns populated; missing + attributes are ``None``. ``raw`` always points at the original + event so callers can fall back to it for any analyzer-specific + introspection. + """ + type_ = event.get("type") + raw_data = event.get("data") + data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} + source = data.get("source") if type_ == _RRWEB_TYPE_INCREMENTAL_SNAPSHOT else None + mouse_type: int | None = None + if source == _RRWEB_SOURCE_MOUSE_INTERACTION: + raw_mouse_type = data.get("type") + if isinstance(raw_mouse_type, int): + mouse_type = raw_mouse_type + target_node_id = data.get("id") if isinstance(data.get("id"), int) else None + url = data.get("href") if type_ == _RRWEB_TYPE_META else None + return { + "t": int(event.get("timestamp", 0)), + "type": type_, + "source": source, + "mouse_type": mouse_type, + "target_node_id": target_node_id, + "url": url, + "raw": event, + } + + +@dataclass(frozen=True) +class Replay(ResultWithDataFrame): + """Single fully-materialized session replay (data-model §2.5). + + Returned by :meth:`Workspace.fetch_replay`. Conceptually a + :class:`ReplayBundle` of size 1; the same DataFrame projections are + available on both. ``fetch_replay`` runs the rrweb analyzer, so + ``actions`` is populated (empty only when the stream yields none). + + Attributes: + replay_id: Mixpanel replay identifier. + distinct_id: Mixpanel user identifier; may be ``None`` for + anonymous sessions. + project_id: Owning project. + start_time: Unix ms timestamp of the first event. + end_time: Unix ms timestamp of the last event. + retention_days: One of ``{1, 7, 30, 90}``. + rrweb_events: Raw rrweb event dicts, timestamp-sorted. + actions: Normalized :class:`UserAction` records produced by the + rrweb analyzer; empty only when extraction yields no actions. + mixpanel_events: Mixpanel events that occurred in the replay + window. Populated only when the caller passed + ``include_mixpanel_events=True`` to ``fetch_replay``. + """ + + replay_id: str + distinct_id: str | None + project_id: int + start_time: int + end_time: int + retention_days: int + rrweb_events: list[dict[str, Any]] = field(default_factory=list) + actions: list[UserAction] = field(default_factory=list) + mixpanel_events: list[ReplayEvent] = field(default_factory=list) + _events_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _actions_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _mixpanel_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + + def __post_init__(self) -> None: + """Validate per data-model §2.5. + + Raises: + ValueError: ``replay_id`` is empty, ``project_id`` is + non-positive, ``start_time`` is non-positive, + ``end_time < start_time``, or ``retention_days`` is + outside ``{1, 7, 30, 90}``. + """ + if not self.replay_id: + raise ValueError("replay_id must be non-empty") + if self.project_id <= 0: + raise ValueError(f"project_id must be positive; got {self.project_id}") + if self.start_time <= 0: + raise ValueError( + f"start_time must be a positive unix ms timestamp; got " + f"{self.start_time}" + ) + if self.end_time < self.start_time: + raise ValueError( + f"end_time must be >= start_time; got start={self.start_time}, " + f"end={self.end_time}" + ) + if self.retention_days not in _ALLOWED_RETENTION_DAYS: + raise ValueError( + f"retention_days must be in {{1, 7, 30, 90}}; got {self.retention_days}" + ) + + def __repr__(self) -> str: + """Concise repr that never serializes the rrweb event payload. + + The default dataclass repr would dump every dict in + ``rrweb_events`` (tens of MB for a real recording, e.g. full DOM + snapshots). This emits counts only so logging, REPL echo, and + tracebacks stay bounded. + + Returns: + A one-line summary: id, user, stream sizes, and duration. + """ + return ( + f"Replay(replay_id={self.replay_id!r}, " + f"distinct_id={self.distinct_id!r}, project_id={self.project_id}, " + f"events={len(self.rrweb_events)}, actions={len(self.actions)}, " + f"mixpanel_events={len(self.mixpanel_events)}, " + f"duration_s={self.duration_seconds:.1f})" + ) + + def __str__(self) -> str: + """Delegate to :meth:`__repr__` so ``print()`` stays bounded.""" + return self.__repr__() + + @property + def duration_seconds(self) -> float: + """Replay duration in seconds. + + Returns: + ``(end_time - start_time) / 1000`` — converts ms to seconds. + """ + return (self.end_time - self.start_time) / 1000 + + @property + def events_df(self) -> pd.DataFrame: + """Long-format projection of raw rrweb events (data-model §2.5). + + Returns: + DataFrame with columns ``t``, ``type``, ``source``, + ``mouse_type``, ``target_node_id``, ``url``, ``raw`` — one + row per rrweb event in input order. Cached after first access. + """ + if self._events_df_cache is not None: + return self._events_df_cache + cols = ["t", "type", "source", "mouse_type", "target_node_id", "url", "raw"] + rows = [_rrweb_event_row(e) for e in self.rrweb_events] + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_events_df_cache", result) + return result + + @property + def actions_df(self) -> pd.DataFrame: + """Long-format projection of normalized actions (data-model §2.5). + + Returns: + DataFrame with columns ``t``, ``action``, ``target_node_id``, + ``target_desc``, ``description``, ``url``, ``metadata`` — one row + per action. ``description`` is the analyzer's full phrase (e.g. + ``'Clicked button "Sign in"'``). Cached after first access. + """ + if self._actions_df_cache is not None: + return self._actions_df_cache + cols = [ + "t", + "action", + "target_node_id", + "target_desc", + "description", + "url", + "metadata", + ] + rows = [ + { + "t": a.timestamp, + "action": a.action, + "target_node_id": a.target_node_id, + "target_desc": a.target_desc, + "description": a.description, + "url": a.url, + "metadata": dict(a.metadata), + } + for a in self.actions + ] + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_actions_df_cache", result) + return result + + @property + def mixpanel_df(self) -> pd.DataFrame: + """Long-format projection of associated Mixpanel events + (data-model §2.5). + + Returns: + DataFrame with columns ``t``, ``event_name``, ``properties`` + — empty when the caller did not pass + ``include_mixpanel_events=True`` to ``fetch_replay``. + Cached after first access. + """ + if self._mixpanel_df_cache is not None: + return self._mixpanel_df_cache + cols = ["t", "event_name", "properties"] + rows = [ + { + "t": e.event_time, + "event_name": e.event_name, + "properties": e.properties, + } + for e in self.mixpanel_events + ] + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_mixpanel_df_cache", result) + return result + + @property + def df(self) -> pd.DataFrame: + """Default projection per FR-018: returns ``actions_df``. + + Returns: + The same DataFrame as ``actions_df``. + """ + return self.actions_df + + def page_path(self) -> list[str]: + """URL sequence visited during the replay. + + Returns: + URLs from the replay's ``navigate`` actions, in timestamp order. + """ + return [ + str(a.url) + for a in self.actions + if a.action == "navigate" and a.url is not None + ] + + def to_rrweb_player_json(self) -> list[dict[str, Any]]: + """Timestamp-sorted rrweb events ready for the rrweb JS player. + + Returns: + A new list of the raw rrweb dicts sorted ascending by + ``timestamp``. The originals are not mutated. + """ + return sorted( + self.rrweb_events, + key=lambda e: int(e.get("timestamp", 0)), + ) + + @property + def summary_markdown(self) -> str: + """Analyzer-produced markdown timeline rendered from ``actions``. + + :meth:`Workspace.fetch_replay` runs the rrweb analyzer; when + ``actions`` is non-empty this returns the markdown timeline. When + ``actions`` is empty (test fixture, no-events fetch) it returns a + one-line placeholder. + + Returns: + Multi-line markdown string suitable for stdout / LLM consumption. + """ + from mixpanel_headless._internal.replays.rrweb_analyzer import ( + _render_markdown, + ) + + if not self.actions: + return f"# Replay {self.replay_id} — no actions extracted\n" + return _render_markdown(self.actions) + + @property + def errors(self) -> pd.DataFrame: + """Console errors captured during the replay. + + Filters the action stream for ``action == "console_error"`` and + projects the ``actions_df`` columns so callers can slice it like + any other action subset. + + Returns: + DataFrame with the ``actions_df`` columns; empty when the + replay had no console errors. + """ + df = self.actions_df + if df.empty: + return df + filtered: pd.DataFrame = df[df["action"] == "console_error"].reset_index( + drop=True + ) + return filtered + + def clicks_on(self, predicate: Callable[[UserAction], bool]) -> pd.DataFrame: + """Filter click actions by an arbitrary predicate. + + Args: + predicate: Callable taking a :class:`UserAction` and returning + ``True`` to include the action. + + Returns: + DataFrame projection (``actions_df``-shaped) of the click + actions for which ``predicate`` returned True. + """ + cols = ["t", "action", "target_node_id", "target_desc", "url", "metadata"] + if not self.actions: + return pd.DataFrame(columns=cols) + keep = [a for a in self.actions if a.action == "click" and predicate(a)] + rows = [ + { + "t": a.timestamp, + "action": a.action, + "target_node_id": a.target_node_id, + "target_desc": a.target_desc, + "url": a.url, + "metadata": dict(a.metadata), + } + for a in keep + ] + return pd.DataFrame(rows, columns=cols) + + def to_dict(self) -> dict[str, Any]: + """JSON-serializable representation. + + Returns: + Dict with the eight visible fields. ``rrweb_events`` is + included so the dict can re-hydrate a full :class:`Replay` + via :meth:`Workspace.fetch_replay` follow-ups. + """ + return { + "replay_id": self.replay_id, + "distinct_id": self.distinct_id, + "project_id": self.project_id, + "start_time": self.start_time, + "end_time": self.end_time, + "retention_days": self.retention_days, + "rrweb_events": list(self.rrweb_events), + "actions": [a.to_dict() for a in self.actions], + "mixpanel_events": [e.to_dict() for e in self.mixpanel_events], + } + + +@dataclass(frozen=True) +class ReplayBundle(ResultWithDataFrame): + """Collection of replays with cross-session projections (data-model §2.6). + + Materialized by + :meth:`Workspace.fetch_replays` and :meth:`Workspace.replays_for_user`. + Inherits :class:`ResultWithDataFrame`; ``df`` returns ``sessions_df`` + (the most useful default — one row per replay with derived counts). + + All DataFrame projections are lazy: computed on first access, cached via + ``object.__setattr__`` since the dataclass is frozen. + + Filters (``filter``, ``where``, ``find_pattern``, ``error_sessions``, + ``head``, ``sample``) return a NEW bundle that is a proper subset of + the original; caches do NOT propagate, so the new bundle re-computes + its projections from its filtered ``replays`` slice. This keeps + chained filters memory-efficient at the cost of one re-compute per + chained step. + + Attributes: + replays: The replays contained in this bundle. + computed_at: ISO-8601 UTC timestamp when this bundle was built. + project_id: Owning Mixpanel project (constant across replays). + """ + + replays: list[Replay] = field(default_factory=list) + computed_at: str = "" + project_id: int = 0 + _sessions_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _actions_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _events_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _mixpanel_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + _elements_df_cache: pd.DataFrame | None = field( + default=None, repr=False, kw_only=True + ) + + def __post_init__(self) -> None: + """Validate that every replay in the bundle shares ``project_id``. + + Raises: + ValueError: A replay's ``project_id`` differs from the + bundle's ``project_id``. + """ + if self.project_id and any( + r.project_id != self.project_id for r in self.replays + ): + mismatches = [ + r.replay_id for r in self.replays if r.project_id != self.project_id + ] + raise ValueError( + f"ReplayBundle.project_id={self.project_id} but the following " + f"replays carry a different project_id: {mismatches}" + ) + + def __repr__(self) -> str: + """Concise repr that never serializes the contained replays. + + Each :class:`Replay` carries its full rrweb event stream, so the + default dataclass repr would dump tens of MB per replay. This emits + the replay count only, keeping logging and tracebacks bounded. + + Returns: + A one-line summary: replay count, project, and computed_at. + """ + return ( + f"ReplayBundle(replays={len(self.replays)}, " + f"project_id={self.project_id}, computed_at={self.computed_at!r})" + ) + + def __str__(self) -> str: + """Delegate to :meth:`__repr__` so ``print()`` stays bounded.""" + return self.__repr__() + + # ========================================================================= + # DataFrame projections + # ========================================================================= + + @property + def sessions_df(self) -> pd.DataFrame: + """One row per replay with derived per-session counts. + + Columns: ``replay_id``, ``distinct_id``, ``start_time``, + ``end_time``, ``duration_s``, ``retention_days``, ``n_events``, + ``n_actions``, ``n_clicks``, ``n_inputs``, ``n_pages``, + ``n_errors``, ``n_mp_events``, ``entry_url``, ``exit_url``. + """ + if self._sessions_df_cache is not None: + return self._sessions_df_cache + cols = [ + "replay_id", + "distinct_id", + "start_time", + "end_time", + "duration_s", + "retention_days", + "n_events", + "n_actions", + "n_clicks", + "n_inputs", + "n_pages", + "n_errors", + "n_mp_events", + "entry_url", + "exit_url", + ] + rows: list[dict[str, Any]] = [] + for r in self.replays: + n_clicks = sum(1 for a in r.actions if a.action == "click") + n_inputs = sum(1 for a in r.actions if a.action == "input") + n_errors = sum(1 for a in r.actions if a.action == "console_error") + navigations = [a for a in r.actions if a.action == "navigate"] + entry_url = navigations[0].url if navigations else None + exit_url = navigations[-1].url if navigations else None + rows.append( + { + "replay_id": r.replay_id, + "distinct_id": r.distinct_id, + "start_time": r.start_time, + "end_time": r.end_time, + "duration_s": r.duration_seconds, + "retention_days": r.retention_days, + "n_events": len(r.rrweb_events), + "n_actions": len(r.actions), + "n_clicks": n_clicks, + "n_inputs": n_inputs, + "n_pages": len(navigations), + "n_errors": n_errors, + "n_mp_events": len(r.mixpanel_events), + "entry_url": entry_url, + "exit_url": exit_url, + } + ) + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_sessions_df_cache", result) + return result + + @property + def actions_df(self) -> pd.DataFrame: + """Long-format actions across all replays. + + Columns: ``replay_id``, ``t``, ``action``, ``target_node_id``, + ``target_desc``, ``description``, ``url``, ``metadata``. + """ + if self._actions_df_cache is not None: + return self._actions_df_cache + cols = [ + "replay_id", + "t", + "action", + "target_node_id", + "target_desc", + "description", + "url", + "metadata", + ] + rows = [ + { + "replay_id": r.replay_id, + "t": a.timestamp, + "action": a.action, + "target_node_id": a.target_node_id, + "target_desc": a.target_desc, + "description": a.description, + "url": a.url, + "metadata": dict(a.metadata), + } + for r in self.replays + for a in r.actions + ] + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_actions_df_cache", result) + return result + + @property + def events_df(self) -> pd.DataFrame: + """Long-format raw rrweb events across all replays. + + Columns: ``replay_id``, ``t``, ``type``, ``source``, ``mouse_type``, + ``target_node_id``, ``url``, ``raw``. + """ + if self._events_df_cache is not None: + return self._events_df_cache + cols = [ + "replay_id", + "t", + "type", + "source", + "mouse_type", + "target_node_id", + "url", + "raw", + ] + rows: list[dict[str, Any]] = [] + for r in self.replays: + for event in r.rrweb_events: + row = _rrweb_event_row(event) + row["replay_id"] = r.replay_id + rows.append(row) + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_events_df_cache", result) + return result + + @property + def mixpanel_df(self) -> pd.DataFrame: + """Long-format Mixpanel events across all replays. + + Columns: ``replay_id``, ``t``, ``event_name``, ``properties``. + Empty when no replay was fetched with ``include_mixpanel_events=True`` + and :meth:`join_mixpanel_events` was not called. + """ + if self._mixpanel_df_cache is not None: + return self._mixpanel_df_cache + cols = ["replay_id", "t", "event_name", "properties"] + rows = [ + { + "replay_id": r.replay_id, + "t": e.event_time, + "event_name": e.event_name, + "properties": e.properties, + } + for r in self.replays + for e in r.mixpanel_events + ] + result = pd.DataFrame(rows, columns=cols) + object.__setattr__(self, "_mixpanel_df_cache", result) + return result + + @property + def elements_df(self) -> pd.DataFrame: + """One row per ``(target_desc, normalized_url)`` with click counts. + + Counts exclude focus-only interactions (a real click fires both a + ``focused`` and a ``clicked`` action; counting both double-counts every + click). URLs are normalized via :func:`url_normalizer` so the same + element on parameterized pages (``/boards#id=1`` vs ``#id=2``) + aggregates into one row instead of fragmenting per URL variant. + + Columns: ``target_desc``, ``url`` (normalized), ``n_clicks``, + ``n_unique_replays``. + """ + if self._elements_df_cache is not None: + return self._elements_df_cache + from mixpanel_headless._internal.replays.aggregators import real_clicks + from mixpanel_headless.replay_labels import url_normalizer + + cols = ["target_desc", "url", "n_clicks", "n_unique_replays"] + clicks = real_clicks(self.actions_df) + if clicks.empty: + result = pd.DataFrame(columns=cols) + else: + normalized = clicks.assign( + url=clicks["url"].map(lambda u: url_normalizer(u) if u else u) + ) + result = ( + normalized.groupby(["target_desc", "url"], dropna=False) + .agg( + n_clicks=("replay_id", "size"), + n_unique_replays=("replay_id", "nunique"), + ) + .reset_index() + ) + object.__setattr__(self, "_elements_df_cache", result) + return result + + @property + def df(self) -> pd.DataFrame: + """Default DataFrame projection for the bundle (data-model §2.6). + + Returns: + The :attr:`sessions_df` projection — one row per replay with + derived per-session counts. Lazily computed and cached. + """ + return self.sessions_df + + # ========================================================================= + # Aggregations + # ========================================================================= + + def top_clicks(self, n: int = 10) -> pd.DataFrame: + """Rank the most-clicked targets across every replay in the bundle. + + Thin wrapper over the bundle-level ``top_clicks`` aggregator, which + counts genuine clicks only — focus-only interactions are excluded so + each user click is counted once. + + Args: + n: Maximum number of click targets to return. Default 10. + + Returns: + A DataFrame with columns ``target_desc`` and ``count``, sorted + descending by ``count``. Empty (with those columns) when the + bundle has no clicks. + + Example: + ```python + bundle.top_clicks(5) + # target_desc count + # 0 button "Sign in" 42 + # 1 link "Pricing" 18 + ``` + """ + from mixpanel_headless._internal.replays.aggregators import top_clicks + + return top_clicks(self, n) + + def rage_clicks(self, threshold: int = 3, window_ms: int = 1000) -> pd.DataFrame: + """Find rage-click bursts: repeated clicks on one target in a tight window. + + Thin wrapper over the bundle-level ``rage_clicks`` aggregator. A burst + is ``threshold`` or more clicks on the same ``target_desc`` whose + timestamps span no more than ``window_ms``. Focus-only interactions + are excluded so burst sizes reflect real clicks. + + Args: + threshold: Minimum clicks for a burst to count. Default 3. + window_ms: Maximum span of the burst, in milliseconds. Default 1000. + + Returns: + A DataFrame with columns ``replay_id``, ``t_start``, + ``target_desc``, and ``count`` — one row per detected burst. + Empty (with those columns) when no burst meets the threshold. + + Example: + ```python + bundle.rage_clicks(threshold=4, window_ms=750) + ``` + """ + from mixpanel_headless._internal.replays.aggregators import rage_clicks + + return rage_clicks(self, threshold=threshold, window_ms=window_ms) + + def long_pauses(self, threshold_s: float = 10) -> pd.DataFrame: + """Find idle stretches between consecutive actions longer than a threshold. + + Thin wrapper over the bundle-level ``long_pauses`` aggregator. Each + gap between two consecutive actions in a replay that is at least + ``threshold_s`` seconds becomes one row. + + Args: + threshold_s: Minimum pause length, in seconds. Default 10. + + Returns: + A DataFrame with columns ``replay_id``, ``t_start`` (the timestamp + of the action preceding the pause), and ``duration_s``. Empty + (with those columns) when no pause meets the threshold. + + Example: + ```python + bundle.long_pauses(threshold_s=30) + ``` + """ + from mixpanel_headless._internal.replays.aggregators import long_pauses + + return long_pauses(self, threshold_s=threshold_s) + + # ========================================================================= + # Filters (return new bundles — immutable semantics) + # ========================================================================= + + def filter(self, predicate: Callable[[Replay], bool]) -> ReplayBundle: + """Return a new bundle containing only the replays matching ``predicate``. + + The base filter primitive behind :meth:`where`, :meth:`find_pattern`, + and :meth:`error_sessions`. The result is a proper subset; DataFrame + caches are NOT propagated, so the new bundle recomputes its + projections from the filtered slice (immutable semantics — ``self`` + is left unchanged). + + Args: + predicate: A callable invoked once per replay; replays for which + it returns ``True`` are kept. + + Returns: + A new :class:`ReplayBundle` carrying the kept replays and the same + ``computed_at`` / ``project_id``. + + Example: + ```python + long_ones = bundle.filter(lambda r: r.duration_seconds > 60) + ``` + """ + return ReplayBundle( + replays=[r for r in self.replays if predicate(r)], + computed_at=self.computed_at, + project_id=self.project_id, + ) + + def where( + self, + *, + distinct_id: str | None = None, + contains_url: str | None = None, + has_event: str | None = None, + min_duration_s: float | None = None, + max_duration_s: float | None = None, + ) -> ReplayBundle: + """Convenience predicate filter; equivalent to a chained ``filter`` call. + + Args: + distinct_id: Keep replays whose ``distinct_id`` matches. + contains_url: Keep replays where any navigation URL includes the + substring. + has_event: Keep replays whose ``mixpanel_events`` include + an event named exactly. + min_duration_s: Keep replays with ``duration_seconds >= min``. + max_duration_s: Keep replays with ``duration_seconds <= max``. + + Returns: + A new :class:`ReplayBundle` (proper subset). + """ + + def _ok(r: Replay) -> bool: + """Apply every supplied predicate; AND-combine the results.""" + if distinct_id is not None and r.distinct_id != distinct_id: + return False + if contains_url is not None and not any( + contains_url in (a.url or "") + for a in r.actions + if a.action == "navigate" + ): + return False + if has_event is not None and not any( + e.event_name == has_event for e in r.mixpanel_events + ): + return False + if min_duration_s is not None and r.duration_seconds < min_duration_s: + return False + return not ( + max_duration_s is not None and r.duration_seconds > max_duration_s + ) + + return self.filter(_ok) + + def find_pattern( + self, + action_sequence: list[str], + *, + label_fn: Callable[[UserAction], str] | None = None, + ) -> ReplayBundle: + """Return a new bundle containing replays whose action labels + include ``action_sequence`` as a contiguous subsequence. + + Args: + action_sequence: Labels to look for, in order. An empty list + matches every replay (returns a full clone of the bundle). + label_fn: Optional label-fn override (defaults to + :func:`default_label_fn`). To group by a stable element + selector, pass :func:`mixpanel_headless.selector_label_fn`. + + Returns: + A new :class:`ReplayBundle`. + """ + from mixpanel_headless.replay_labels import default_label_fn + + fn = label_fn or default_label_fn + target = tuple(action_sequence) + if not target: + return ReplayBundle( + replays=list(self.replays), + computed_at=self.computed_at, + project_id=self.project_id, + ) + + def _matches(r: Replay) -> bool: + """True when r's label sequence contains target as a contiguous run.""" + labels = [fn(a) for a in r.actions] + for i in range(len(labels) - len(target) + 1): + if tuple(labels[i : i + len(target)]) == target: + return True + return False + + return self.filter(_matches) + + def error_sessions(self) -> ReplayBundle: + """Return a new bundle of only the replays that emitted a console error. + + Delegates to the ``error_sessions`` aggregator to collect the IDs of + replays with at least one ``console_error`` action, then filters down + to them. The result is a proper subset (immutable semantics — ``self`` + is left unchanged). + + Returns: + A new :class:`ReplayBundle` containing only error-bearing replays; + empty when the bundle has no console errors. + + Example: + ```python + for replay in bundle.error_sessions().replays: + print(replay.replay_id) + ``` + """ + from mixpanel_headless._internal.replays.aggregators import ( + error_sessions as _ids, + ) + + ids = set(_ids(self)) + return self.filter(lambda r: r.replay_id in ids) + + def head(self, n: int = 5) -> ReplayBundle: + """Return a new bundle containing the first ``n`` replays, in order. + + Order-preserving counterpart to :meth:`sample`. The result is a proper + subset (immutable semantics — ``self`` is left unchanged). + + Args: + n: How many leading replays to keep. Values larger than the bundle + size keep every replay. Default 5. + + Returns: + A new :class:`ReplayBundle` with at most ``n`` replays and the same + ``computed_at`` / ``project_id``. + + Example: + ```python + preview = bundle.head(3) + ``` + """ + return ReplayBundle( + replays=list(self.replays[:n]), + computed_at=self.computed_at, + project_id=self.project_id, + ) + + def sample(self, n: int = 5, seed: int | None = None) -> ReplayBundle: + """Return a new bundle with up to ``n`` replays, deterministic per ``seed``. + + Args: + n: How many replays to sample. + seed: Optional seed for reproducible sampling. + + Returns: + A new :class:`ReplayBundle` whose ``replays`` list has length + ``min(n, len(self.replays))``. + """ + import random + + rng = random.Random(seed) + # rng.sample raises when k > population; clamp first. + k = min(n, len(self.replays)) + chosen = rng.sample(list(self.replays), k=k) + return ReplayBundle( + replays=chosen, + computed_at=self.computed_at, + project_id=self.project_id, + ) + + # ========================================================================= + # Enrichment / summary / comparison + # ========================================================================= + + def join_mixpanel_events(self, properties: list[str] | None = None) -> ReplayBundle: + """Return a new bundle whose ``mixpanel_df`` is populated. + + In this implementation the join requires callers to have fetched + replays with ``include_mixpanel_events=True``; the bundle simply + re-exposes the already-attached events. + + Args: + properties: Reserved for the future on-demand-join variant. + + Returns: + A new :class:`ReplayBundle` with the same replays — kept as a + distinct object so callers can rely on the immutable-semantics + contract. + """ + _ = properties + return ReplayBundle( + replays=list(self.replays), + computed_at=self.computed_at, + project_id=self.project_id, + ) + + @property + def summary_markdown(self) -> str: + """Markdown rollup of the bundle: header totals plus per-session timelines. + + Builds a ``# Bundle summary`` header with replay / event / action / + error totals, then appends each replay's own + :attr:`Replay.summary_markdown` separated by horizontal rules. A + replay whose per-replay summary is unavailable degrades to a bare + ``## Replay `` heading rather than failing the whole rollup. + + Returns: + A markdown string. When the bundle is empty, returns + ``"# No replays in bundle\\n"``. + """ + if not self.replays: + return "# No replays in bundle\n" + sections = ["# Bundle summary", "", f"- replays: {len(self.replays)}"] + df = self.sessions_df + if not df.empty: + sections.append(f"- total events: {int(df['n_events'].sum())}") + sections.append(f"- total actions: {int(df['n_actions'].sum())}") + sections.append(f"- total errors: {int(df['n_errors'].sum())}") + sections.append("") + for r in self.replays: + try: + sections.append(r.summary_markdown) + except NotImplementedError: + sections.append(f"## Replay {r.replay_id}") + sections.append("\n---\n") + return "\n".join(sections) + + def compare(self, other: ReplayBundle) -> pd.DataFrame: + """Compare action frequencies between this bundle and ``other``. + + Args: + other: The bundle to diff against. + + Returns: + DataFrame with columns ``action``, ``self_count``, + ``other_count``, ``delta`` (self - other). + """ + a = ( + self.actions_df["action"].value_counts() + if not self.actions_df.empty + else pd.Series(dtype=int) + ) + b = ( + other.actions_df["action"].value_counts() + if not other.actions_df.empty + else pd.Series(dtype=int) + ) + keys = sorted(set(a.index) | set(b.index)) + rows = [ + { + "action": k, + "self_count": int(a.get(k, 0)), + "other_count": int(b.get(k, 0)), + "delta": int(a.get(k, 0)) - int(b.get(k, 0)), + } + for k in keys + ] + return pd.DataFrame( + rows, columns=["action", "self_count", "other_count", "delta"] + ) + + def to_dict(self) -> dict[str, Any]: + """Serialize the bundle to a JSON-friendly dict (lossy on DataFrames). + + The DataFrame projections (``sessions_df``, ``actions_df``, …) are + derived views and are NOT included; only the source replays plus + bundle metadata are serialized. Consumers rebuild the projections on + demand rather than persisting them. + + Returns: + A dict with keys ``computed_at``, ``project_id``, and ``replays`` + (each replay serialized via :meth:`Replay.to_dict`). + """ + return { + "computed_at": self.computed_at, + "project_id": self.project_id, + "replays": [r.to_dict() for r in self.replays], + } diff --git a/src/mixpanel_headless/workspace.py b/src/mixpanel_headless/workspace.py index ee5817a2..556539f6 100644 --- a/src/mixpanel_headless/workspace.py +++ b/src/mixpanel_headless/workspace.py @@ -25,13 +25,16 @@ from __future__ import annotations +import asyncio import calendar +import contextlib import json import logging import math import time from collections.abc import Iterator, Sequence from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import replace from datetime import date as _date from datetime import datetime, timezone from pathlib import Path @@ -93,6 +96,10 @@ from mixpanel_headless._internal.segfilter import build_segfilter_entry from mixpanel_headless._internal.services.discovery import DiscoveryService from mixpanel_headless._internal.services.live_query import LiveQueryService +from mixpanel_headless._internal.services.replays import ( + ReplaysService, + replay_not_found_error, +) from mixpanel_headless._internal.transforms import transform_event, transform_profile from mixpanel_headless._internal.validation import ( _scan_custom_properties, @@ -232,6 +239,10 @@ PublicWorkspace, QueryResult, ReplaceSchemaEnforcementParams, + Replay, + ReplayBundle, + ReplayEvent, + ReplaySummary, RetentionAlignment, RetentionEvent, RetentionMathType, @@ -245,6 +256,7 @@ SchemaGraphResult, SegmentationResult, SetTestUsersParams, + SignedReplay, SubPropertyInfo, TimeComparison, TopEvent, @@ -283,6 +295,27 @@ _MAX_LIMIT = 100_000 +def _check_event_properties_count(event_properties: list[str] | None) -> None: + """Raise ``ValueError`` when ``event_properties`` exceeds the Insights cap. + + Mixpanel's Insights API caps group-by at 5 properties; the + session-replay ``events_for_replay(s)`` and ``fetch_replay(include=)`` + surfaces all pass through to that endpoint, so the cap applies uniformly. + + Args: + event_properties: Caller-supplied list (or None). + + Raises: + ValueError: Per error-messages.md §4 wording. + """ + if event_properties is not None and len(event_properties) > 5: + raise ValueError( + f"events_for_replay accepts at most 5 event_properties " + f"(Insights group-by limit). Got {len(event_properties)}: " + f"{event_properties}" + ) + + def _validate_limit(limit: int | None) -> None: """Validate limit is within the allowed range. @@ -416,6 +449,9 @@ def __init__( self._discovery: DiscoveryService | None = None self._live_query: LiveQueryService | None = None self._me_service: MeService | None = None + # 044-session-replay: lazy ReplaysService, created on first replay-method + # access so non-replay sessions never pay for the import or async client. + self._replays_svc: ReplaysService | None = None if session is not None: sess = session @@ -621,6 +657,7 @@ def use( self._discovery = None self._live_query = None self._me_service = None + self._replays_svc = None if persist: self._persist_active() @@ -950,6 +987,22 @@ def _live_query_service(self) -> LiveQueryService: self._live_query = LiveQueryService(self._require_api_client()) return self._live_query + @property + def _replays_service(self) -> ReplaysService: + """Get or create the session-replay service (044, lazy initialization). + + Constructed on first access with the bound :meth:`query` so + :meth:`ReplaysService.discover` and :meth:`ReplaysService.events_for` + can issue Insights queries without taking a hard dependency on + :class:`Workspace`. + """ + if self._replays_svc is None: + self._replays_svc = ReplaysService( + self._require_api_client(), + query_fn=self.query, + ) + return self._replays_svc + # ========================================================================= # DISCOVERY METHODS # ========================================================================= @@ -10302,3 +10355,617 @@ def get_business_context_chain(self) -> BusinessContextChain: project_id=self._session.project.id, ), ) + + # ========================================================================= + # Session Replay (044-session-replay) + # ========================================================================= + + def list_replays( + self, + *, + distinct_id: str | None = None, + replay_ids: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + limit: int = 100, + ) -> list[ReplaySummary]: + """List replays for a user, or hydrate summaries for explicit IDs. + + Issues one Insights query against ``$mp_session_record`` grouped on + ``$mp_replay_id`` and ``$mp_replay_retention_period`` (and ``$time`` + for the start-time column), then collapses the result rows into + :class:`ReplaySummary` objects. + + Exactly one of ``distinct_id`` or ``replay_ids`` MUST be provided. + When ``distinct_id`` is set, ``from_date`` and ``to_date`` are + required. When ``replay_ids`` is given, the date window is + inferred from the events themselves and the kwargs are optional. + + Args: + distinct_id: Mixpanel user identifier. Mutually exclusive with + ``replay_ids``. + replay_ids: Explicit list of replay IDs to hydrate. Mutually + exclusive with ``distinct_id``. + from_date: ISO date string (YYYY-MM-DD). Required with + ``distinct_id``. + to_date: ISO date string (YYYY-MM-DD). Required with + ``distinct_id``. + limit: Maximum summaries to return. Default 100. + + Returns: + List of :class:`ReplaySummary`, possibly empty. + + Raises: + ValueError: Neither or both of ``distinct_id`` and ``replay_ids`` + were provided; or ``distinct_id`` was set without a date window. + QueryError: Underlying Insights API failure. + + Example: + ```python + ws = mp.Workspace() + for s in ws.list_replays( + distinct_id="u-42", + from_date="2026-05-20", + to_date="2026-05-27", + ): + print(s.replay_id, s.retention_days) + ``` + """ + if distinct_id is None and not replay_ids: + raise ValueError( + "list_replays requires exactly one of distinct_id or replay_ids." + ) + if distinct_id is not None and replay_ids: + raise ValueError( + "list_replays requires exactly one of distinct_id or " + "replay_ids; both were given." + ) + if distinct_id is not None and (from_date is None or to_date is None): + raise ValueError( + "list_replays(distinct_id=...) requires from_date and to_date." + ) + + return self._replays_service.discover( + distinct_id=distinct_id, + replay_ids=replay_ids, + from_date=from_date, + to_date=to_date, + limit=limit, + ) + + def events_for_replay( + self, + replay_id: str, + *, + event_properties: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + ) -> list[ReplayEvent]: + """Mixpanel events that occurred during a single replay's time window. + + Args: + replay_id: The replay to fetch events for. + event_properties: Up to 5 additional event properties to include + as group keys. + from_date: ISO date (YYYY-MM-DD) lower bound for the events scan. + When omitted, a 90-day lookback is used (covers the maximum + retention window). Pass an explicit window — e.g. the replay's + own day — to scope the scan tightly. + to_date: ISO date (YYYY-MM-DD) upper bound; paired with + ``from_date``. + + Returns: + Ordered list of :class:`ReplayEvent`. Empty when the replay + window contains no Mixpanel events. + + Raises: + ValueError: ``len(event_properties) > 5`` (Insights group-by cap). + QueryError: Underlying Insights API failure. + """ + _check_event_properties_count(event_properties) + bundle = self._replays_service.events_for( + [replay_id], + event_properties=event_properties, + from_date=from_date, + to_date=to_date, + ) + return bundle.get(replay_id, []) + + def events_for_replays( + self, + replay_ids: list[str], + *, + event_properties: list[str] | None = None, + from_date: str | None = None, + to_date: str | None = None, + ) -> dict[str, list[ReplayEvent]]: + """Batched version of :meth:`events_for_replay`. Single round-trip. + + Args: + replay_ids: Replays to fetch events for. + event_properties: Up to 5 additional event properties to include + as group keys. + from_date: ISO date (YYYY-MM-DD) lower bound for the events scan. + When omitted, a 90-day lookback is used (covers the maximum + retention window) so events for older-but-retained replays are + not silently missed. + to_date: ISO date (YYYY-MM-DD) upper bound; paired with + ``from_date``. + + Returns: + Dict mapping ``replay_id`` → ordered :class:`ReplayEvent` list. + Replays with no events are omitted from the dict. + + Raises: + ValueError: ``len(event_properties) > 5``. + QueryError: Underlying Insights API failure. + """ + _check_event_properties_count(event_properties) + return self._replays_service.events_for( + replay_ids, + event_properties=event_properties, + from_date=from_date, + to_date=to_date, + ) + + def sign_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + ) -> SignedReplay: + """Sign a single replay ID; sugar over :meth:`sign_replays`. + + Args: + replay_id: Replay to sign. + env: ``"prod"`` (default) or ``"dev"``. + + Returns: + One :class:`SignedReplay`. ``query_string`` is a 5-minute bearer + credential — treat it like a session token. + + Raises: + SessionReplayAccessError: Project has sensitive-data flag set. + APIError: Other 4xx / 5xx on the sign endpoint. + """ + return self._replays_service.sign([replay_id], env=env)[0] + + def sign_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", + ) -> list[SignedReplay]: + """Sign multiple replays via the bulk endpoint. + + Args: + replay_ids: Replays to sign. + env: ``"prod"`` (default) or ``"dev"``. + + Returns: + List of :class:`SignedReplay` in input order. + + Raises: + SessionReplayAccessError: Project has sensitive-data flag set. + APIError: Other 4xx / 5xx. + """ + return self._replays_service.sign(replay_ids, env=env) + + def fetch_replay( + self, + replay_id: str, + *, + distinct_id: str | None = None, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + cdn_concurrency: int = 50, + ) -> Replay: + """Sign, fetch, and assemble a single :class:`Replay`. + + Runs the vendored rrweb analyzer to populate ``Replay.actions``. + The raw ``rrweb_events`` list is also populated and exposed for + downstream tools (e.g. the rrweb JS player). + + This is a synchronous method that drives the async CDN walk via + ``asyncio.run``. It therefore cannot be called from inside a running + event loop (Jupyter, a FastAPI handler, etc.) — that raises + ``RuntimeError: asyncio.run() cannot be called from a running event + loop``. From async code, drive + :meth:`ReplaysService.walk_cdn_async` directly instead. + + Args: + replay_id: The replay to fetch. + distinct_id: Optional user id to stamp on the returned + :class:`Replay`. Threaded through by callers that know it + (e.g. :meth:`replays_for_user`); ``None`` leaves it unset. + env: ``"prod"`` (default) or ``"dev"``. + retention_days: 1, 7, 30, or 90. Auto-discovered when ``None`` + via a single ``list_replays`` round-trip. + max_files: Hard upper bound on CDN file walk (default 500). + include_mixpanel_events: When True, follow with a + :meth:`events_for_replay` call and populate + :attr:`Replay.mixpanel_events`. + event_properties: Up to 5 extra properties for the Mixpanel + join query (only used when ``include_mixpanel_events`` is + True). + cdn_concurrency: Parallel batch size for CDN fetches. + + Returns: + A :class:`Replay` with ``rrweb_events`` populated. + + Raises: + ReplayNotFoundError: First CDN file returned 404. + SessionReplayAccessError: Sensitive-data flag set. + SignedURLExpiredError: Signed URL expired during fetch (rare; + fetch signs and fetches immediately). + ValueError: ``len(event_properties) > 5``. + """ + _check_event_properties_count(event_properties) + resolved_retention = self._resolve_retention(replay_id, retention_days) + signed = self._replays_service.sign([replay_id], env=env)[0] + rrweb_events = self._replays_service.fetch_files( + signed, + retention_days=resolved_retention, + max_files=max_files, + concurrency=cdn_concurrency, + ) + if not rrweb_events: + raise replay_not_found_error( + replay_id, + retention_days=resolved_retention, + cdn_url_prefix=signed.url, + ) + + # Derive the window from min/max rather than first/last: walk_cdn_async + # yields in (file-number, in-file timestamp) order with no global merge, + # so indexing [0]/[-1] would drift if CDN files ever overlap in time. + event_timestamps = [int(ev["timestamp"]) for ev in rrweb_events] + start_time = min(event_timestamps) + end_time = max(event_timestamps) + + mixpanel_events: list[ReplayEvent] = [] + if include_mixpanel_events: + # Scope the events scan to the replay's own day(s): tight, and + # correct even for replays older than the default 90-day lookback. + win_from = datetime.fromtimestamp(start_time / 1000, timezone.utc).strftime( + "%Y-%m-%d" + ) + win_to = datetime.fromtimestamp(end_time / 1000, timezone.utc).strftime( + "%Y-%m-%d" + ) + mixpanel_events = self.events_for_replay( + replay_id, + event_properties=event_properties, + from_date=win_from, + to_date=win_to, + ) + + # Run the rrweb analyzer to populate actions. + from mixpanel_headless._internal.replays.rrweb_analyzer import RrwebAnalyzer + + analyzer_result = RrwebAnalyzer().analyze(rrweb_events) + return Replay( + replay_id=replay_id, + distinct_id=distinct_id, + project_id=int(self._session.project.id), + start_time=start_time, + end_time=end_time, + retention_days=resolved_retention, + rrweb_events=rrweb_events, + actions=list(analyzer_result.actions), + mixpanel_events=mixpanel_events, + ) + + def stream_replay( + self, + replay_id: str, + *, + env: Literal["prod", "dev"] = "prod", + retention_days: int | None = None, + max_files: int = 500, + re_sign_on_expiry: bool = True, + cdn_concurrency: int = 50, + ) -> Iterator[dict[str, Any]]: + """Yield raw rrweb events one at a time, batched-parallel under the hood. + + Drives :meth:`ReplaysService.walk_cdn_async` via a private event + loop so callers consume from a normal sync iterator. The underlying + AsyncClient closes when the generator is exhausted or closed. + + Like :meth:`fetch_replay`, this manages its own event loop and so + cannot be called from inside a running one (Jupyter, async handlers); + consume :meth:`ReplaysService.walk_cdn_async` directly in that case. + + Args: + replay_id: The replay to stream. + env: ``"prod"`` (default) or ``"dev"``. + retention_days: 1, 7, 30, or 90. Auto-discovered when ``None``. + max_files: Hard upper bound on CDN file walk. + re_sign_on_expiry: When True (default), catches mid-walk 403s + indicating signature expiration and re-signs once + transparently. When False, propagates + :class:`SignedURLExpiredError`. + cdn_concurrency: Parallel batch size. + + Yields: + Raw rrweb event dicts in timestamp order. + + Raises: + ReplayNotFoundError: First CDN file returned 404. + SignedURLExpiredError: Re-sign retry exhausted or disabled. + SessionReplayAccessError: Sensitive-data flag set. + """ + resolved_retention = self._resolve_retention(replay_id, retention_days) + signed = self._replays_service.sign([replay_id], env=env)[0] + + loop = asyncio.new_event_loop() + gen = self._replays_service.walk_cdn_async( + signed, + retention_days=resolved_retention, + max_files=max_files, + concurrency=cdn_concurrency, + re_sign_on_expiry=re_sign_on_expiry, + ) + try: + while True: + try: + event = loop.run_until_complete(gen.__anext__()) + except StopAsyncIteration: + return + yield event + finally: + with contextlib.suppress(RuntimeError, StopAsyncIteration): + loop.run_until_complete(gen.aclose()) + loop.close() + + def fetch_replays( + self, + replay_ids: list[str], + *, + env: Literal["prod", "dev"] = "prod", + max_files: int = 500, + include_mixpanel_events: bool = False, + event_properties: list[str] | None = None, + concurrency: int = 4, + cdn_concurrency: int = 50, + retention_by_id: dict[str, int] | None = None, + distinct_id_by_id: dict[str, str] | None = None, + ) -> ReplayBundle: + """Fetch N replays in parallel; return a :class:`ReplayBundle`. + + Materializes each replay via :meth:`fetch_replay` (signed CDN walk + + analyzer) and bundles them. Outer ``concurrency`` parallelizes + across replays; inner ``cdn_concurrency`` parallelizes the + per-replay CDN file walk. Threads are used at the outer level so + each replay's async event loop runs in isolation. + + To keep Insights round-trips bounded (matching the reference MCP + server, which batches), this method: + + - passes a caller-supplied ``retention_by_id`` to each + :meth:`fetch_replay` so it skips the per-replay retention-discovery + query when the caller already knows the value (e.g. + :meth:`replays_for_user`, which gets it from ``list_replays``); and + - when ``include_mixpanel_events`` is set, joins Mixpanel events in a + single :meth:`events_for_replays` call across every fetched replay + rather than one query per replay. + + Per-replay failures are isolated: a replay that 404s, stalls, or fails + to parse is logged and skipped; only an all-fail batch raises (the + first underlying error, preserving its type). + + Like :meth:`fetch_replay`, each worker drives ``asyncio.run`` in its + own thread, so this is not safe to call from inside a running event + loop. + + Args: + replay_ids: Replays to fetch. + env: ``"prod"`` (default) or ``"dev"``. + max_files: Per-replay CDN bound. + include_mixpanel_events: Join Mixpanel events (one batched query + across all replays). + event_properties: Up to 5 properties for the join. + concurrency: Replay-level parallelism (thread count). Note the + connection floor multiplies: up to ``concurrency`` + ✕ ``cdn_concurrency`` open CDN connections (default 4 ✕ 50 = + 200) plus one event loop per worker thread. Raise both knobs + with that product in mind. + cdn_concurrency: Per-replay CDN parallelism. + retention_by_id: Optional ``{replay_id: retention_days}`` map that + lets each fetch skip its retention-discovery round-trip. + distinct_id_by_id: Optional ``{replay_id: distinct_id}`` map so each + fetched :class:`Replay` is stamped with its user (e.g. + :meth:`replays_for_user` passes this from ``list_replays``). + + Returns: + A :class:`ReplayBundle` with ``replays`` populated in input order + (failed replays omitted). + + Raises: + MixpanelHeadlessError: Only when every requested replay failed; + the first underlying error propagates with its type. + """ + _check_event_properties_count(event_properties) + retention_map = retention_by_id or {} + distinct_map = distinct_id_by_id or {} + # Use a thread pool so each fetch_replay invocation owns its own async + # event loop without clashing. Events are joined once after assembly + # (below), not per replay — so each fetch runs with + # include_mixpanel_events=False here regardless of the caller's flag. + results: dict[int, Replay] = {} + failures: list[tuple[str, Exception]] = [] + with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool: + futures = { + pool.submit( + self.fetch_replay, + rid, + distinct_id=distinct_map.get(rid), + env=env, + retention_days=retention_map.get(rid), + max_files=max_files, + include_mixpanel_events=False, + cdn_concurrency=cdn_concurrency, + ): (i, rid) + for i, rid in enumerate(replay_ids) + } + for future in as_completed(futures): + idx, rid = futures[future] + try: + results[idx] = future.result() + except Exception as exc: # noqa: BLE001 — per-replay isolation + # One replay's CDN stall, 404, or parse error must not sink + # the whole bundle (mirrors the MCP server's + # asyncio.gather(return_exceptions=True) + skip). Log it and + # keep the successful replays; only an all-fail batch raises. + logger.warning( + "fetch_replays: skipping replay %s — %s: %s", + rid, + type(exc).__name__, + exc, + ) + failures.append((rid, exc)) + if not results and failures: + # Every replay failed — surface the first underlying error rather + # than a generic wrapper, preserving its type (ReplayNotFoundError, + # SignedURLExpiredError, ...) for callers that branch on it. + raise failures[0][1] + ordered = [results[i] for i in sorted(results)] + + # Join Mixpanel events in ONE query across all replays (the per-replay + # alternative fans out N queries and exhausts the Insights rate limit). + # The combined window spans the earliest start to the latest end. + if include_mixpanel_events and ordered: + win_from = datetime.fromtimestamp( + min(r.start_time for r in ordered) / 1000, timezone.utc + ).strftime("%Y-%m-%d") + win_to = datetime.fromtimestamp( + max(r.end_time for r in ordered) / 1000, timezone.utc + ).strftime("%Y-%m-%d") + events_by_replay = self.events_for_replays( + [r.replay_id for r in ordered], + event_properties=event_properties, + from_date=win_from, + to_date=win_to, + ) + ordered = [ + replace(r, mixpanel_events=events_by_replay[r.replay_id]) + if r.replay_id in events_by_replay + else r + for r in ordered + ] + return ReplayBundle( + replays=ordered, + computed_at=datetime.now(timezone.utc).isoformat(), + project_id=int(self._session.project.id), + ) + + def replays_for_user( + self, + distinct_id: str, + *, + from_date: str, + to_date: str, + limit: int = 20, + include_mixpanel_events: bool = True, + event_properties: list[str] | None = None, + ) -> ReplayBundle: + """Discovery + fetch in one call. + + Composes :meth:`list_replays` and :meth:`fetch_replays`. Defaults + ``include_mixpanel_events`` to True since this is the "show me + what this user did" convenience method — having the Mixpanel + event stream alongside the actions is usually what callers want. + + Each replay materializes its full byte stream, so the default + ``limit`` is a conservative 20 (matching the reference MCP server's + ``MCP_MAX_REPLAYS_TO_PROCESS``). An active user can have hundreds of + replays in a week; fetching them all is byte-heavy and slow. Raise + ``limit`` deliberately when you need more, or use + :meth:`list_replays` + :meth:`stream_replay` for large sweeps. + + Args: + distinct_id: Mixpanel user identifier. + from_date: ISO date (YYYY-MM-DD). + to_date: ISO date (YYYY-MM-DD). + limit: Maximum replays to fetch. Default 20 (byte-heavy per replay). + include_mixpanel_events: Default True for this convenience method. + event_properties: Up to 5 properties for Mixpanel join. + + Returns: + A :class:`ReplayBundle`; empty when no replays exist in the + window. + + Raises: + ValueError: ``len(event_properties) > 5`` or invalid dates. + """ + _check_event_properties_count(event_properties) + summaries = self.list_replays( + distinct_id=distinct_id, + from_date=from_date, + to_date=to_date, + limit=limit, + ) + if not summaries: + return ReplayBundle( + replays=[], + computed_at=datetime.now(timezone.utc).isoformat(), + project_id=int(self._session.project.id), + ) + return self.fetch_replays( + [s.replay_id for s in summaries], + include_mixpanel_events=include_mixpanel_events, + event_properties=event_properties, + # We already discovered each replay's retention in the list_replays + # call above — pass it through so fetch_replay skips re-discovering + # it per replay (one fewer Insights query each). + retention_by_id={s.replay_id: s.retention_days for s in summaries}, + # Every replay was discovered for this user — stamp it so + # sessions_df / Replay.distinct_id identify who the session belongs to. + distinct_id_by_id={s.replay_id: distinct_id for s in summaries}, + ) + + def analyze_replay(self, replay_id: str) -> str: + """Sign + fetch + analyze a replay, returning only the markdown timeline. + + Sugar for ``self.fetch_replay(replay_id).summary_markdown`` for callers + (and the ``mp replays analyze`` CLI) that want the rendered timeline and + not the full :class:`Replay`. The analyzer always runs as part of + :meth:`fetch_replay`; this just discards everything but the markdown. + + Inherits :meth:`fetch_replay`'s event-loop constraint — not callable + from inside a running event loop. + + Args: + replay_id: The replay to analyze. + + Returns: + The markdown timeline string (the replay's + :attr:`Replay.summary_markdown`). + + Raises: + ReplayNotFoundError: First CDN file returned 404. + SessionReplayAccessError: Sensitive-data flag set. + """ + return self.fetch_replay(replay_id).summary_markdown + + def _resolve_retention(self, replay_id: str, retention_days: int | None) -> int: + """Resolve a replay's retention window, discovering it when None. + + Args: + replay_id: The replay to look up. + retention_days: Caller-provided value; pass-through when set. + + Returns: + One of 1, 7, 30, or 90. Defaults to 30 when discovery returns + no summary (with the warning already emitted by + :meth:`ReplaysService.discover`). + """ + if retention_days is not None: + return retention_days + summaries = self.list_replays(replay_ids=[replay_id]) + if summaries: + return summaries[0].retention_days + return 30 diff --git a/tests/fixtures/rrweb/README.md b/tests/fixtures/rrweb/README.md new file mode 100644 index 00000000..479263d8 --- /dev/null +++ b/tests/fixtures/rrweb/README.md @@ -0,0 +1,56 @@ +# rrweb test fixtures + +Hand-built rrweb event streams for unit + property tests of the session-replay +feature (044). These are deliberately tiny — they exercise event-shape parsing, +not realistic recording size or complexity. + +## `sample-replay-001.json` + +~20 events covering a minimal login → navigate → click flow: + +1. `DomContentLoaded` (type 0) +2. `Load` (type 1) +3. `Meta` (type 4) — initial URL `/login`, viewport 1280×800 +4. `FullSnapshot` (type 2) — login form DOM (email/password/submit button with + `data-testid="signin-button"`) +5. `IncrementalSnapshot` MouseMove (type 3 / source 1) +6. `IncrementalSnapshot` Input (type 3 / source 5) on email field +7. `IncrementalSnapshot` Input (type 3 / source 5) on password field +8–10. `IncrementalSnapshot` MouseInteraction (type 3 / source 2): + MouseDown → MouseUp → Click on the Sign In button (`#13`) +11. `Meta` — navigate to `/dashboard` +12. `FullSnapshot` — dashboard with a link to user 12345's profile +13. `IncrementalSnapshot` Scroll (type 3 / source 3) +14. `IncrementalSnapshot` Click (type 3 / source 2 / type 2) on the user link +15. `Meta` — navigate to `/dashboard/users/12345/profile` +16. `FullSnapshot` — profile page with an `data-testid="edit-profile"` button +17. `IncrementalSnapshot` MouseMove +18. `IncrementalSnapshot` Click on the edit button +19. `IncrementalSnapshot` ViewportResize (type 3 / source 4) — 1280×800 → 1024×768 +20. `Meta` — navigate to `/dashboard/users/12345/edit` + +Total duration: 15 seconds (`timestamp` field uses unix ms starting at +`1716810000000` = 2024-05-27 13:00:00 UTC). The stream is timestamp-sorted +and contains at least one of every rrweb event family the analyzer cares +about: DOM bootstrap, navigation, mouse input, keyboard input, +viewport change. + +## rrweb event-shape reference + +- `type: 0` — `DomContentLoaded` +- `type: 1` — `Load` +- `type: 2` — `FullSnapshot` (carries `data.node` + `data.initialOffset`) +- `type: 3` — `IncrementalSnapshot` (carries `data.source` discriminator) +- `type: 4` — `Meta` (carries `data.href`, `data.width`, `data.height`) +- `type: 5` — `Custom` +- `type: 6` — `Plugin` + +`IncrementalSnapshot.data.source` values used by this fixture: + +| `source` | Family | Extras | +|----------|-------------------|-------------------------------------------| +| 1 | MouseMove | `positions: [{x, y, id, timeOffset}]` | +| 2 | MouseInteraction | `type: 0=Up / 1=Down / 2=Click`, `x`, `y` | +| 3 | Scroll | `id`, `x`, `y` | +| 4 | ViewportResize | `width`, `height` | +| 5 | Input | `id`, `text`, `isChecked` | diff --git a/tests/fixtures/rrweb/sample-replay-001.json b/tests/fixtures/rrweb/sample-replay-001.json new file mode 100644 index 00000000..8b7dfaf1 --- /dev/null +++ b/tests/fixtures/rrweb/sample-replay-001.json @@ -0,0 +1,299 @@ +[ + { + "type": 0, + "data": {}, + "timestamp": 1716810000000 + }, + { + "type": 1, + "data": {}, + "timestamp": 1716810000010 + }, + { + "type": 4, + "data": { + "href": "https://app.example.com/login", + "width": 1280, + "height": 800 + }, + "timestamp": 1716810000020 + }, + { + "type": 2, + "data": { + "node": { + "id": 1, + "type": 0, + "childNodes": [ + { + "id": 2, + "type": 2, + "tagName": "html", + "attributes": {"lang": "en"}, + "childNodes": [ + { + "id": 3, + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "id": 10, + "type": 2, + "tagName": "form", + "attributes": {"id": "login-form"}, + "childNodes": [ + { + "id": 11, + "type": 2, + "tagName": "input", + "attributes": {"type": "email", "name": "email", "id": "email"}, + "childNodes": [] + }, + { + "id": 12, + "type": 2, + "tagName": "input", + "attributes": {"type": "password", "name": "password", "id": "password"}, + "childNodes": [] + }, + { + "id": 13, + "type": 2, + "tagName": "button", + "attributes": {"type": "submit", "data-testid": "signin-button"}, + "childNodes": [ + {"id": 14, "type": 3, "textContent": "Sign in"} + ] + } + ] + } + ] + } + ] + } + ] + }, + "initialOffset": {"left": 0, "top": 0} + }, + "timestamp": 1716810000030 + }, + { + "type": 3, + "data": { + "source": 1, + "positions": [ + {"x": 100, "y": 100, "id": 11, "timeOffset": 0}, + {"x": 200, "y": 150, "id": 11, "timeOffset": 50} + ] + }, + "timestamp": 1716810001000 + }, + { + "type": 3, + "data": { + "source": 5, + "id": 11, + "text": "user@example.com", + "isChecked": false + }, + "timestamp": 1716810002000 + }, + { + "type": 3, + "data": { + "source": 5, + "id": 12, + "text": "********", + "isChecked": false + }, + "timestamp": 1716810003000 + }, + { + "type": 3, + "data": { + "source": 2, + "type": 1, + "id": 13, + "x": 300, + "y": 400 + }, + "timestamp": 1716810003500 + }, + { + "type": 3, + "data": { + "source": 2, + "type": 0, + "id": 13, + "x": 300, + "y": 400 + }, + "timestamp": 1716810003520 + }, + { + "type": 3, + "data": { + "source": 2, + "type": 2, + "id": 13, + "x": 300, + "y": 400 + }, + "timestamp": 1716810003540 + }, + { + "type": 4, + "data": { + "href": "https://app.example.com/dashboard", + "width": 1280, + "height": 800 + }, + "timestamp": 1716810004000 + }, + { + "type": 2, + "data": { + "node": { + "id": 100, + "type": 0, + "childNodes": [ + { + "id": 101, + "type": 2, + "tagName": "html", + "attributes": {"lang": "en"}, + "childNodes": [ + { + "id": 102, + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "id": 110, + "type": 2, + "tagName": "a", + "attributes": {"href": "/dashboard/users/12345/profile"}, + "childNodes": [ + {"id": 111, "type": 3, "textContent": "View user 12345"} + ] + } + ] + } + ] + } + ] + }, + "initialOffset": {"left": 0, "top": 0} + }, + "timestamp": 1716810004500 + }, + { + "type": 3, + "data": { + "source": 3, + "id": 102, + "x": 0, + "y": 200 + }, + "timestamp": 1716810006000 + }, + { + "type": 3, + "data": { + "source": 2, + "type": 2, + "id": 110, + "x": 150, + "y": 250 + }, + "timestamp": 1716810008000 + }, + { + "type": 4, + "data": { + "href": "https://app.example.com/dashboard/users/12345/profile", + "width": 1280, + "height": 800 + }, + "timestamp": 1716810009000 + }, + { + "type": 2, + "data": { + "node": { + "id": 200, + "type": 0, + "childNodes": [ + { + "id": 201, + "type": 2, + "tagName": "html", + "attributes": {"lang": "en"}, + "childNodes": [ + { + "id": 202, + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "id": 210, + "type": 2, + "tagName": "button", + "attributes": {"data-testid": "edit-profile"}, + "childNodes": [ + {"id": 211, "type": 3, "textContent": "Edit profile"} + ] + } + ] + } + ] + } + ] + }, + "initialOffset": {"left": 0, "top": 0} + }, + "timestamp": 1716810009500 + }, + { + "type": 3, + "data": { + "source": 1, + "positions": [ + {"x": 400, "y": 300, "id": 210, "timeOffset": 0} + ] + }, + "timestamp": 1716810011000 + }, + { + "type": 3, + "data": { + "source": 2, + "type": 2, + "id": 210, + "x": 400, + "y": 300 + }, + "timestamp": 1716810012000 + }, + { + "type": 3, + "data": { + "source": 4, + "width": 1024, + "height": 768 + }, + "timestamp": 1716810013000 + }, + { + "type": 4, + "data": { + "href": "https://app.example.com/dashboard/users/12345/edit", + "width": 1024, + "height": 768 + }, + "timestamp": 1716810015000 + } +] diff --git a/tests/integration/test_replays_live.py b/tests/integration/test_replays_live.py new file mode 100644 index 00000000..aa403960 --- /dev/null +++ b/tests/integration/test_replays_live.py @@ -0,0 +1,123 @@ +"""Live integration tests for session replay (044). + +Skipped by default — set ``MP_LIVE_TESTS=1`` and point auth at a fixture +project (e.g. Mixpanel Labs project 3713224) with a known replay-bearing +distinct_id. These tests round-trip against the real ``/replays/sign`` and +the real CDN. + +Markers: +- ``@pytest.mark.live`` lets the rest of the suite skip them via + ``-m "not live"``. +- ``@pytest.mark.skipif`` short-circuits when ``MP_LIVE_TESTS`` is absent. +""" + +from __future__ import annotations + +import os + +import pytest + +import mixpanel_headless as mp +from mixpanel_headless.exceptions import SessionReplayAccessError + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("MP_LIVE_TESTS") != "1", + reason="MP_LIVE_TESTS=1 not set — live tests skipped by default", + ), +] + + +# Override per-environment by exporting MP_REPLAY_FIXTURE_DISTINCT_ID and +# MP_REPLAY_FIXTURE_PROJECT before running. +_FIXTURE_DISTINCT_ID = os.environ.get( + "MP_REPLAY_FIXTURE_DISTINCT_ID", "fixture-distinct-id" +) +_FIXTURE_FROM_DATE = os.environ.get("MP_REPLAY_FIXTURE_FROM", "2026-05-01") +_FIXTURE_TO_DATE = os.environ.get("MP_REPLAY_FIXTURE_TO", "2026-05-27") + + +@pytest.fixture +def ws() -> mp.Workspace: + """Workspace bound to the configured live fixture project.""" + return mp.Workspace() + + +class TestListReplaysLive: + """list_replays returns ≥1 summary for the known distinct_id.""" + + def test_returns_at_least_one_summary(self, ws: mp.Workspace) -> None: + """The fixture user has at least one replay in the window.""" + summaries = ws.list_replays( + distinct_id=_FIXTURE_DISTINCT_ID, + from_date=_FIXTURE_FROM_DATE, + to_date=_FIXTURE_TO_DATE, + ) + assert len(summaries) >= 1 + assert summaries[0].replay_id + assert summaries[0].retention_days in (1, 7, 30, 90) + + +class TestSignAndCdnLive: + """sign_replays returns valid signed URLs; CDN HEAD returns 200.""" + + def test_signed_url_serves_cdn(self, ws: mp.Workspace) -> None: + """A signed URL for the first discovered replay HEADs OK from the CDN.""" + import httpx + + summaries = ws.list_replays( + distinct_id=_FIXTURE_DISTINCT_ID, + from_date=_FIXTURE_FROM_DATE, + to_date=_FIXTURE_TO_DATE, + ) + if not summaries: + pytest.skip("no replays in fixture window — fixture probably stale") + + signed = ws.sign_replay(summaries[0].replay_id) + url = ( + f"{signed.url}0000-{summaries[0].retention_days}.json?{signed.query_string}" + ) + with httpx.Client(timeout=30.0) as client: + response = client.head(url) + # Either 200 (file exists) or 404 (replay aged out / sparse files) + # is acceptable; 403 would indicate a signing problem. + assert response.status_code in (200, 404) + + +class TestFetchReplayLive: + """fetch_replay returns a Replay with non-zero duration.""" + + def test_fetch_returns_replay_with_events(self, ws: mp.Workspace) -> None: + """Materializing the first replay yields ≥1 rrweb event.""" + summaries = ws.list_replays( + distinct_id=_FIXTURE_DISTINCT_ID, + from_date=_FIXTURE_FROM_DATE, + to_date=_FIXTURE_TO_DATE, + ) + if not summaries: + pytest.skip("no replays in fixture window — fixture probably stale") + + replay = ws.fetch_replay( + summaries[0].replay_id, retention_days=summaries[0].retention_days + ) + assert len(replay.rrweb_events) >= 1 + assert replay.duration_seconds >= 0 + + +class TestSensitiveDataLive: + """Sensitive-data fixture project raises SessionReplayAccessError. + + Only runs when ``MP_REPLAY_SENSITIVE_PROJECT`` is set, since most + fixture projects aren't sensitive-data-gated. + """ + + def test_sensitive_data_project_raises_access_error(self, ws: mp.Workspace) -> None: + """Targeting a project with the flag set raises with structured details.""" + sensitive_project = os.environ.get("MP_REPLAY_SENSITIVE_PROJECT") + if not sensitive_project: + pytest.skip("MP_REPLAY_SENSITIVE_PROJECT not set") + sens_ws = mp.Workspace(project=sensitive_project) + with pytest.raises(SessionReplayAccessError) as exc_info: + sens_ws.sign_replays(["any-replay-id"]) + assert exc_info.value.details["flag"] == "SESSION_RECORDING_SENSITIVE_DATA" diff --git a/tests/pbt/test_cdn_walker_pbt.py b/tests/pbt/test_cdn_walker_pbt.py new file mode 100644 index 00000000..adf6c6de --- /dev/null +++ b/tests/pbt/test_cdn_walker_pbt.py @@ -0,0 +1,193 @@ +"""Property-based tests for the session-replay CDN walker (044). + +Invariants verified across randomly generated 404 positions and per-file +event counts: + +- The walker terminates at exactly the 404 position (or at ``max_files`` + when no 404 falls in the bound). +- The 404 file is never re-fetched. +- ``max_files`` is respected even when the 404 sentinel falls beyond it. +- Returned events are sorted by ``timestamp`` regardless of in-batch + fetch ordering. +- A 404 at position 0 raises :class:`ReplayNotFoundError` rather than + returning an empty list (the "replay doesn't exist on CDN" signal). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock +from urllib.parse import urlparse + +import httpx +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from mixpanel_headless._internal.services.replays import ReplaysService +from mixpanel_headless.exceptions import ReplayNotFoundError +from mixpanel_headless.types import SignedReplay + + +def _signed() -> SignedReplay: + """Build a SignedReplay pointing at the fake CDN host.""" + return SignedReplay( + replay_id="r-pbt", + url="https://cdn.test/srr-us/sha-pbt/", + query_string="URLPrefix=A&Signature=S", + env="prod", + signed_at=1716810000.0, + ) + + +def _file_num(url: str) -> int: + """Extract the NNNN index from a CDN URL like ``...0007-30.json?...``.""" + last = urlparse(url).path.rsplit("/", 1)[-1] + return int(last.split("-", 1)[0]) + + +def _mock_api() -> MagicMock: + """A minimal MixpanelAPIClient stand-in for ReplaysService construction.""" + api = MagicMock() + api.project_id = "12345" + api.sign_replays = MagicMock(return_value=[]) + return api + + +# Bounded to keep PBT runs fast: 0 ≤ k ≤ 50 for the 404 position; up to 5 +# events per file; max_files capped at 100. Wider ranges would just churn +# CPU without exercising additional logic. +@settings(deadline=None, max_examples=50) +@given( + k=st.integers(min_value=1, max_value=50), + event_counts=st.lists( + st.integers(min_value=1, max_value=5), + min_size=51, + max_size=51, + ), + max_files=st.integers(min_value=1, max_value=100), + concurrency=st.integers(min_value=1, max_value=10), +) +def test_walker_terminates_at_404_and_respects_max_files( + k: int, + event_counts: list[int], + max_files: int, + concurrency: int, +) -> None: + """Walker stops at exactly min(k, max_files); 404 is never re-fetched.""" + requested: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + """Serve 200s with ``event_counts[n]`` events until file ``k`` → 404.""" + file_num = _file_num(str(request.url)) + requested.append(file_num) + if file_num >= k: + return httpx.Response(404) + events = [ + {"type": 3, "data": {}, "timestamp": file_num * 1000 + i} + for i in range(event_counts[file_num]) + ] + return httpx.Response(200, json=events) + + api = _mock_api() + transport = httpx.MockTransport(handler) + service = ReplaysService(api, _async_transport=transport) + + if k >= max_files: + # Bound reached before the 404 → no exception, capped output. + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=max_files, + concurrency=concurrency, + ) + # All file numbers fetched are within [0, max_files). + assert all(n < max_files for n in requested) + # We expect events for files [0, max_files) since none hit the + # 404 sentinel within the bound. + expected = sum(event_counts[n] for n in range(max_files)) + assert len(events) == expected + else: + # k < max_files → 404 sentinel terminates the walk cleanly. + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=max_files, + concurrency=concurrency, + ) + # The 404 file was fetched once at most. + assert requested.count(k) <= 1 + # No file >= k contributed events. + assert sum(event_counts[n] for n in range(k)) == len(events) + + +@settings(deadline=None, max_examples=30) +@given( + event_counts=st.lists( + st.integers(min_value=1, max_value=5), + min_size=10, + max_size=10, + ), + concurrency=st.integers(min_value=1, max_value=4), +) +def test_walker_returns_timestamp_sorted_events( + event_counts: list[int], + concurrency: int, +) -> None: + """Output is sorted ascending by ``timestamp`` regardless of fetch order.""" + + # Use deliberately scrambled timestamps within each file so the in-file + # sort matters: file N contributes timestamps in reverse order. + def handler(request: httpx.Request) -> httpx.Response: + """Serve files with deliberately out-of-order in-file timestamps.""" + file_num = _file_num(str(request.url)) + if file_num >= 10: + return httpx.Response(404) + n_events = event_counts[file_num] + # Reverse-order within the file; sort must put them back in order. + events = [ + {"type": 3, "data": {}, "timestamp": file_num * 1000 + (n_events - i)} + for i in range(n_events) + ] + return httpx.Response(200, json=events) + + api = _mock_api() + transport = httpx.MockTransport(handler) + service = ReplaysService(api, _async_transport=transport) + + events = service.fetch_files( + _signed(), retention_days=30, max_files=500, concurrency=concurrency + ) + # Within each file: timestamps strictly ascending (the in-file sort). + # Across files: monotonically non-decreasing because file N+1's + # timestamps start at (N+1)*1000 > N*1000+n_events for our generator + # (n_events ≤ 5, gap = 1000). + ts = [int(e["timestamp"]) for e in events] + assert ts == sorted(ts) + + +@settings(deadline=None, max_examples=20) +@given(concurrency=st.integers(min_value=1, max_value=10)) +def test_first_file_404_always_raises_replay_not_found(concurrency: int) -> None: + """404 at position 0 ALWAYS raises ReplayNotFoundError (never empty list).""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Serve 404 for every file — first-file 404 is the not-found signal.""" + return httpx.Response(404) + + api = _mock_api() + transport = httpx.MockTransport(handler) + service = ReplaysService(api, _async_transport=transport) + + with pytest.raises(ReplayNotFoundError): + service.fetch_files( + _signed(), + retention_days=30, + max_files=10, + concurrency=concurrency, + ) + + +# Keep Any reachable so the file's typing surface is non-empty for tools +# that scan for unused-import suppressions. +_ = Any diff --git a/tests/pbt/test_replays_series_pbt.py b/tests/pbt/test_replays_series_pbt.py new file mode 100644 index 00000000..11ee3bf7 --- /dev/null +++ b/tests/pbt/test_replays_series_pbt.py @@ -0,0 +1,119 @@ +"""Property-based tests for the session-replay series flattener (044). + +`_flatten_series` walks a nested Insights ``series`` dict in group-by order, +skipping the ``$overall`` rollup key at every level, and emits one flat row per +surviving leaf. Invariants verified across randomly generated nestings: + +- Every emitted row has exactly the group-by keys plus ``count``. +- ``$overall`` never leaks into a group-key column (rollups are skipped at + every level). +- The flattener recovers exactly the non-rollup leaves that were planted — + no more, no fewer — regardless of how many ``$overall`` siblings are injected. +- The walk is deterministic across repeated calls. +""" + +from __future__ import annotations + +from typing import Any + +from hypothesis import given, settings +from hypothesis import strategies as st + +from mixpanel_headless._internal.services.replays import _flatten_series + +# Group-key values: short tokens that are never the rollup sentinel. +_safe_keys = st.text( + alphabet="abcdefghijkmnpqrstuvwxyz0123456789-", min_size=1, max_size=8 +).filter(lambda s: s != "$overall") + + +@st.composite +def _series_node( + draw: st.DrawFn, group_names: list[str] +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Build ``(node, expected_rows)`` nesting ``len(group_names)`` levels. + + Plants 1–3 real (non-rollup) keys at each level and optionally injects an + ``$overall`` rollup sibling that the flattener must ignore. + + Args: + draw: Hypothesis draw callable. + group_names: Remaining group-key names to nest, outermost first. + + Returns: + A ``(node, expected_rows)`` pair: the nested dict and the exact list of + flattened rows ``_flatten_series`` should produce for it. + """ + prop = group_names[0] + rest = group_names[1:] + keys = draw(st.lists(_safe_keys, min_size=1, max_size=3, unique=True)) + + node: dict[str, Any] = {} + rows: list[dict[str, Any]] = [] + for key in keys: + if rest: + child, child_rows = draw(_series_node(rest)) + node[key] = child + rows.extend({prop: key, **row} for row in child_rows) + else: + value = draw(st.integers(min_value=1, max_value=10**13)) + node[key] = {"all": value} + rows.append({prop: key, "count": value}) + + # Inject a rollup sibling that MUST be skipped (any shape — never descended). + if draw(st.booleans()): + node["$overall"] = {"all": draw(st.integers(min_value=0, max_value=10**13))} + return node, rows + + +@st.composite +def _series_and_expected( + draw: st.DrawFn, +) -> tuple[dict[str, Any], list[str], list[dict[str, Any]]]: + """Build a full ``(series, group_by, expected_rows)`` example. + + Args: + draw: Hypothesis draw callable. + + Returns: + ``(series, group_by, expected_rows)`` — a one-metric series, the + group-by names matching its nesting depth, and the exact rows the + flattener should emit. + """ + depth = draw(st.integers(min_value=1, max_value=4)) + group_names = [f"g{i}" for i in range(depth)] + node, rows = draw(_series_node(group_names)) + metric = draw(_safe_keys) + return {metric: node}, group_names, rows + + +def _normalize(rows: list[dict[str, Any]]) -> list[tuple[tuple[str, Any], ...]]: + """Order-independent canonical form for a row list.""" + return sorted(tuple(sorted(row.items())) for row in rows) + + +@given(_series_and_expected()) +@settings(max_examples=200) +def test_flatten_recovers_leaves_and_skips_overall( + data: tuple[dict[str, Any], list[str], list[dict[str, Any]]], +) -> None: + """Flattener recovers exactly the planted leaves; no $overall leaks.""" + series, group_by, expected = data + rows = _flatten_series(series, group_by) + + for row in rows: + assert set(row) == set(group_by) | {"count"} + for key in group_by: + assert row[key] != "$overall" + + assert _normalize(rows) == _normalize(expected) + + +@given(_series_and_expected()) +@settings(max_examples=100) +def test_flatten_is_deterministic( + data: tuple[dict[str, Any], list[str], list[dict[str, Any]]], +) -> None: + """Repeated calls on the same series yield identical rows.""" + series, group_by, _expected = data + assert _flatten_series(series, group_by) == _flatten_series(series, group_by) diff --git a/tests/unit/_internal/test_api_client_sign_replays.py b/tests/unit/_internal/test_api_client_sign_replays.py new file mode 100644 index 00000000..df8287a3 --- /dev/null +++ b/tests/unit/_internal/test_api_client_sign_replays.py @@ -0,0 +1,250 @@ +"""Unit tests for MixpanelAPIClient.sign_replays (044-session-replay). + +Covers: +- POST body shape: `{"replays": [{"replay_id": ..., "replay_env": ...}, ...]}` +- URL: `/app/projects//replays/sign/bulk` +- 403 + SESSION_RECORDING_SENSITIVE_DATA → SessionReplayAccessError with details +- Other 4xx/5xx pass through to the existing APIError / ServerError mapping +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +from tests.conftest import make_session + +from mixpanel_headless._internal.api_client import MixpanelAPIClient +from mixpanel_headless._internal.auth.session import Session +from mixpanel_headless.exceptions import ( + APIError, + QueryError, + ServerError, + SessionReplayAccessError, +) + + +@pytest.fixture +def us_credentials() -> Session: + """US-region service-account session for sign-endpoint tests.""" + return make_session( + username="test_user", + secret="test_secret", + project_id="12345", + region="us", + ) + + +def _client(credentials: Session, handler: Any) -> MixpanelAPIClient: + """Build an API client with an httpx.MockTransport-backed handler.""" + transport = httpx.MockTransport(handler) + return MixpanelAPIClient(session=credentials, _transport=transport) + + +# ============================================================================= +# Happy path: request shape + response passthrough +# ============================================================================= + + +class TestSignReplaysRequest: + """Request URL and JSON body shape.""" + + def test_posts_to_bulk_endpoint(self, us_credentials: Session) -> None: + """Hits POST /api/app/projects//replays/sign/bulk on the US host.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + captured["method"] = request.method + captured["url"] = str(request.url) + return httpx.Response(200, json={"results": []}) + + with _client(us_credentials, handler) as client: + client.sign_replays(["r-1"], env="prod") + + assert captured["method"] == "POST" + assert ( + "https://mixpanel.com/api/app/projects/12345/replays/sign/bulk" + in captured["url"] + ) + + def test_request_body_shape(self, us_credentials: Session) -> None: + """Body is `{"replays": [{"replay_id": ..., "replay_env": "prod"}, ...]}`.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"results": []}) + + with _client(us_credentials, handler) as client: + client.sign_replays(["r-1", "r-2"], env="prod") + + assert captured["body"] == { + "replays": [ + {"replay_id": "r-1", "replay_env": "prod"}, + {"replay_id": "r-2", "replay_env": "prod"}, + ] + } + + def test_request_body_propagates_env_dev(self, us_credentials: Session) -> None: + """env='dev' propagates to every replay entry.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"results": []}) + + with _client(us_credentials, handler) as client: + client.sign_replays(["r-1"], env="dev") + + assert captured["body"]["replays"][0]["replay_env"] == "dev" + + def test_returns_raw_results_list(self, us_credentials: Session) -> None: + """Returns the `results` array contents (raw decoded dicts), in input order.""" + response_results = [ + { + "replay_id": "r-1", + "url": "https://cdn.mxpnl.com/srr-us/sha-12345/", + "query_string": "URLPrefix=A&Expires=1&KeyName=K&Signature=S", + }, + { + "replay_id": "r-2", + "url": "https://cdn.mxpnl.com/srr-us/sha2-12345/", + "query_string": "URLPrefix=B&Expires=2&KeyName=K&Signature=S", + }, + ] + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response(200, json={"results": response_results}) + + with _client(us_credentials, handler) as client: + result = client.sign_replays(["r-1", "r-2"], env="prod") + + assert result == response_results + + def test_default_env_is_prod(self, us_credentials: Session) -> None: + """env defaults to 'prod' when omitted.""" + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"results": []}) + + with _client(us_credentials, handler) as client: + client.sign_replays(["r-1"]) + + assert captured["body"]["replays"][0]["replay_env"] == "prod" + + +# ============================================================================= +# 403 → SessionReplayAccessError mapping +# ============================================================================= + + +class TestSensitiveDataMapping: + """SESSION_RECORDING_SENSITIVE_DATA 403 → SessionReplayAccessError.""" + + def test_403_with_flag_raises_session_replay_access_error( + self, us_credentials: Session + ) -> None: + """403 body containing SESSION_RECORDING_SENSITIVE_DATA → SessionReplayAccessError.""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response( + 403, + json={ + "error": ( + "Your project has sensitive replay data. Set " + "SESSION_RECORDING_SENSITIVE_DATA to access." + ) + }, + ) + + with ( + _client(us_credentials, handler) as client, + pytest.raises(SessionReplayAccessError) as exc_info, + ): + client.sign_replays(["r-1"], env="prod") + + exc = exc_info.value + assert exc.status_code == 403 + assert exc.details["project_id"] == 12345 + assert exc.details["flag"] == "SESSION_RECORDING_SENSITIVE_DATA" + assert exc.details["permission_required"] == "sensitive_data_replay" + + def test_403_without_flag_passes_through_to_query_error( + self, us_credentials: Session + ) -> None: + """A 403 without the SESSION_RECORDING_SENSITIVE_DATA marker falls through.""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response(403, json={"error": "Permission denied"}) + + with ( + _client(us_credentials, handler) as client, + pytest.raises(QueryError) as exc_info, + ): + client.sign_replays(["r-1"], env="prod") + + assert not isinstance(exc_info.value, SessionReplayAccessError) + assert exc_info.value.status_code == 403 + + +# ============================================================================= +# Pass-through for other HTTP errors +# ============================================================================= + + +class TestOtherHttpErrors: + """4xx and 5xx not matching the sensitive-data 403 pattern use the existing mapping.""" + + def test_400_raises_query_error(self, us_credentials: Session) -> None: + """400 → QueryError (existing behavior).""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response(400, json={"error": "Bad request"}) + + with ( + _client(us_credentials, handler) as client, + pytest.raises(QueryError), + ): + client.sign_replays(["r-1"], env="prod") + + def test_500_raises_server_error(self, us_credentials: Session) -> None: + """5xx → ServerError (existing behavior).""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response(500, json={"error": "Internal server error"}) + + with ( + _client(us_credentials, handler) as client, + pytest.raises(ServerError), + ): + client.sign_replays(["r-1"], env="prod") + + def test_non_replay_403_is_not_session_replay_access_error( + self, us_credentials: Session + ) -> None: + """Plain 403 (no sensitive-data marker) is still an APIError but not the replay subclass.""" + + def handler(_request: httpx.Request) -> httpx.Response: + """Mock HTTP handler returning a canned httpx.Response for this test.""" + return httpx.Response(403, json={"error": "Generic permission denied"}) + + with ( + _client(us_credentials, handler) as client, + pytest.raises(APIError) as exc_info, + ): + client.sign_replays(["r-1"], env="prod") + + assert not isinstance(exc_info.value, SessionReplayAccessError) diff --git a/tests/unit/_internal/test_replays_service.py b/tests/unit/_internal/test_replays_service.py new file mode 100644 index 00000000..2b12d6c8 --- /dev/null +++ b/tests/unit/_internal/test_replays_service.py @@ -0,0 +1,714 @@ +"""Unit tests for ReplaysService (044-session-replay). + +Locks the documented behavior of the CDN walker: parallel batches, 404 +sentinel, 403 re-sign retry, max_files bound, timestamp ordering, and +forward-compat mobile-replay detection. ``MixpanelAPIClient.sign_replays`` +is mocked at the method level; CDN GETs use ``httpx.MockTransport`` so +``httpx.AsyncClient`` traffic stays in-process. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock +from urllib.parse import urlparse + +import httpx +import pytest + +from mixpanel_headless._internal.services.replays import ReplaysService +from mixpanel_headless.exceptions import ( + MixpanelHeadlessError, + ReplayNotFoundError, + SignedURLExpiredError, + UnsupportedReplayFormatError, +) +from mixpanel_headless.types import SignedReplay + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _mock_api_client(project_id: str = "12345") -> MagicMock: + """Build a stand-in for MixpanelAPIClient with the minimum surface used.""" + api = MagicMock() + api.project_id = project_id + api.sign_replays = MagicMock(return_value=[]) + return api + + +def _signed(replay_id: str = "r-1", *, env: str = "prod") -> SignedReplay: + """Build a SignedReplay pointing at the fake CDN host used in fixtures.""" + return SignedReplay( + replay_id=replay_id, + url=f"https://cdn.test/srr-us/sha-{replay_id}/", + query_string="URLPrefix=A&Expires=1&KeyName=K&Signature=S", + env=env, # type: ignore[arg-type] + signed_at=1716810000.0, + ) + + +def _rrweb_event(timestamp: int, type_: int = 3) -> dict[str, Any]: + """Build a minimal rrweb-shaped event.""" + return {"type": type_, "data": {}, "timestamp": timestamp} + + +def _parse_file_num(url: str) -> int: + """Pull the NNNN file index out of a CDN URL like '...0007-30.json?...'.""" + path = urlparse(url).path + last = path.rsplit("/", 1)[-1] + # last looks like '0007-30.json' + num_part = last.split("-", 1)[0] + return int(num_part) + + +# ============================================================================= +# sign() +# ============================================================================= + + +class TestSignWrapping: + """ReplaysService.sign wraps api_client.sign_replays in SignedReplay objects.""" + + def test_sign_returns_list_of_signed_replay(self) -> None: + """Each raw dict from the API becomes one SignedReplay in input order.""" + api = _mock_api_client() + api.sign_replays.return_value = [ + { + "replay_id": "r-1", + "url": "https://cdn.test/srr-us/sha-1/", + "query_string": "URLPrefix=A&Signature=X", + }, + { + "replay_id": "r-2", + "url": "https://cdn.test/srr-us/sha-2/", + "query_string": "URLPrefix=B&Signature=Y", + }, + ] + service = ReplaysService(api) + + result = service.sign(["r-1", "r-2"], env="prod") + + assert len(result) == 2 + assert all(isinstance(r, SignedReplay) for r in result) + assert result[0].replay_id == "r-1" + assert result[1].replay_id == "r-2" + assert result[0].env == "prod" + assert result[0].signed_at > 0 + api.sign_replays.assert_called_once_with(["r-1", "r-2"], env="prod") + + +# ============================================================================= +# fetch_files() — the CDN walker +# ============================================================================= + + +def _make_cdn_handler( + *, + file_contents: dict[int, list[dict[str, Any]] | None] | None = None, + files_403: set[int] | None = None, + call_log: list[int] | None = None, +) -> Any: + """Build an httpx.MockTransport handler that serves CDN file fixtures. + + Args: + file_contents: Maps file number → events to return (200), or None + to return 404. Numbers absent from this dict 404 by default. + files_403: File numbers that should return 403 (overrides + file_contents). + call_log: Optional list to append every requested file number to. + + Returns: + Callable suitable for ``httpx.MockTransport(handler)``. + """ + file_contents = file_contents or {} + files_403 = files_403 or set() + + def handler(request: httpx.Request) -> httpx.Response: + """Serve one CDN file based on the file number parsed from the URL.""" + file_num = _parse_file_num(str(request.url)) + if call_log is not None: + call_log.append(file_num) + if file_num in files_403: + return httpx.Response(403, text="signature expired") + if file_num not in file_contents or file_contents[file_num] is None: + return httpx.Response(404) + return httpx.Response(200, json=file_contents[file_num]) + + return handler + + +class TestFetchFilesHappyPath: + """Buffered fetch concatenates all files and sorts by timestamp.""" + + def test_returns_timestamp_sorted_events(self) -> None: + """Events from all CDN files are concatenated in timestamp order.""" + # Out-of-order timestamps within a file, plus files served in + # arbitrary order, should all come back sorted. + file_contents: dict[int, list[dict[str, Any]] | None] = { + 0: [_rrweb_event(20), _rrweb_event(10)], + 1: [_rrweb_event(40), _rrweb_event(30)], + 2: None, # 404 → end of replay + } + api = _mock_api_client() + transport = httpx.MockTransport(_make_cdn_handler(file_contents=file_contents)) + service = ReplaysService(api, _async_transport=transport) + + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + ) + + timestamps = [e["timestamp"] for e in events] + assert timestamps == [10, 20, 30, 40] + + def test_uses_correct_file_naming(self) -> None: + """File URLs use the {NNNN:04d}-{retention_days}.json pattern (FR-013). + + With concurrency=1 the walker fetches one file at a time, so the + 404 sentinel at file 2 terminates the walk before file 3 is hit. + Wider batches issue the whole batch in parallel and inspect 404s + after the gather; that case is exercised by test_respects_max_files_bound. + """ + call_log: list[int] = [] + file_contents: dict[int, list[dict[str, Any]] | None] = { + 0: [_rrweb_event(10)], + 1: [_rrweb_event(20)], + 2: None, + } + api = _mock_api_client() + transport = httpx.MockTransport( + _make_cdn_handler(file_contents=file_contents, call_log=call_log) + ) + service = ReplaysService(api, _async_transport=transport) + + service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=1, + ) + # Sequential walk stops the moment file 2 returns 404. + assert sorted(call_log) == [0, 1, 2] + + def test_respects_max_files_bound(self) -> None: + """max_files caps the walk even when no 404 ever appears.""" + # Every file returns one event; no 404 would naturally stop us. + file_contents: dict[int, list[dict[str, Any]] | None] = { + n: [_rrweb_event(n * 10)] for n in range(200) + } + call_log: list[int] = [] + api = _mock_api_client() + transport = httpx.MockTransport( + _make_cdn_handler(file_contents=file_contents, call_log=call_log) + ) + service = ReplaysService(api, _async_transport=transport) + + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=10, + concurrency=4, + ) + + assert len(events) == 10 + assert max(call_log) == 9 + + +class TestFetchFilesTermination: + """404 handling: first-file → ReplayNotFoundError; mid-walk → clean stop.""" + + def test_first_file_404_raises_replay_not_found(self) -> None: + """A 404 on file 0000-N.json means the replay doesn't exist on CDN.""" + # Empty file_contents → every file 404s. + api = _mock_api_client() + transport = httpx.MockTransport(_make_cdn_handler()) + service = ReplaysService(api, _async_transport=transport) + + with pytest.raises(ReplayNotFoundError) as exc_info: + service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + ) + + exc = exc_info.value + assert exc.details["replay_id"] == "r-1" + assert exc.details["retention_days"] == 30 + assert exc.details["cdn_url_prefix"].endswith("/") + + def test_mid_walk_404_terminates_cleanly(self) -> None: + """A 404 on a non-first file is the end-of-replay sentinel (no raise).""" + file_contents: dict[int, list[dict[str, Any]] | None] = { + 0: [_rrweb_event(10)], + 1: [_rrweb_event(20)], + 2: [_rrweb_event(30)], + # file 3 absent → 404 + } + api = _mock_api_client() + transport = httpx.MockTransport(_make_cdn_handler(file_contents=file_contents)) + service = ReplaysService(api, _async_transport=transport) + + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + ) + assert [e["timestamp"] for e in events] == [10, 20, 30] + + +class TestFetchFiles403Retry: + """403 mid-walk: re-sign once when allowed, raise when disabled.""" + + def test_403_with_re_sign_succeeds_after_resign(self) -> None: + """403 mid-walk triggers one re-sign + retry when re_sign_on_expiry=True.""" + # Simulate: first request to file 0 returns 403; after re-sign, + # everything is fine. Use a stateful handler that flips behavior + # after the re-sign call shows up in api.sign_replays. + state = {"resigned": False} + + def handler(request: httpx.Request) -> httpx.Response: + """Return 403 until we've re-signed; then serve the file normally.""" + file_num = _parse_file_num(str(request.url)) + if file_num >= 2: + return httpx.Response(404) + if not state["resigned"]: + return httpx.Response(403, text="signature expired") + return httpx.Response(200, json=[_rrweb_event(file_num * 10)]) + + api = _mock_api_client() + + def _sign_replays(_ids: list[str], env: str = "prod") -> list[dict[str, Any]]: + """Mark re-signed and return a fresh credential dict.""" + state["resigned"] = True + return [ + { + "replay_id": "r-1", + "url": "https://cdn.test/srr-us/sha-1/", + "query_string": "URLPrefix=Z&Signature=NEW", + } + ] + + api.sign_replays.side_effect = _sign_replays + transport = httpx.MockTransport(handler) + service = ReplaysService(api, _async_transport=transport) + + events = service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + re_sign_on_expiry=True, + ) + # Re-sign was called exactly once. + assert api.sign_replays.call_count == 1 + # After re-sign we got the events for files 0 and 1. + assert [e["timestamp"] for e in events] == [0, 10] + + def test_403_without_re_sign_raises_expired(self) -> None: + """re_sign_on_expiry=False propagates the expiry as SignedURLExpiredError.""" + api = _mock_api_client() + transport = httpx.MockTransport(_make_cdn_handler(files_403={0})) + service = ReplaysService(api, _async_transport=transport) + + with pytest.raises(SignedURLExpiredError) as exc_info: + service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + re_sign_on_expiry=False, + ) + + exc = exc_info.value + assert exc.details["replay_id"] == "r-1" + assert "signed_at" in exc.details + assert "expired_at" in exc.details + assert api.sign_replays.call_count == 0 + + +class TestFetchFilesCredentialRedaction: + """Transport errors must never leak the signed query_string credential.""" + + def test_transport_error_redacts_signed_credential(self) -> None: + """An httpx error whose str() embeds the URL is scrubbed before raising.""" + signed = _signed() + + def handler(request: httpx.Request) -> httpx.Response: + """Raise a transport error whose message embeds the credentialed URL.""" + raise httpx.ConnectError(f"connection failed for {request.url}") + + api = _mock_api_client() + transport = httpx.MockTransport(handler) + service = ReplaysService(api, _async_transport=transport) + + with pytest.raises(MixpanelHeadlessError) as exc_info: + service.fetch_files( + signed, retention_days=30, max_files=500, concurrency=50 + ) + + message = str(exc_info.value) + assert signed.query_string not in message + assert "" in message + + +# ============================================================================= +# Mobile-replay detection (forward-compat marker) +# ============================================================================= + + +class TestMobileReplayDetection: + """First event missing rrweb keys → UnsupportedReplayFormatError per §9.""" + + def test_non_rrweb_first_event_raises_unsupported_format(self) -> None: + """Mobile replays use a different recording format; fail with a typed error.""" + # First file's first event lacks 'type', 'data', and 'timestamp'. + file_contents: dict[int, list[dict[str, Any]] | None] = { + 0: [{"mobile_event": "tap", "ts": 1716810000}], + 1: None, + } + api = _mock_api_client() + transport = httpx.MockTransport(_make_cdn_handler(file_contents=file_contents)) + service = ReplaysService(api, _async_transport=transport) + + with pytest.raises(UnsupportedReplayFormatError, match="mobile session") as ei: + service.fetch_files( + _signed(), + retention_days=30, + max_files=500, + concurrency=50, + ) + # Typed so the CLI maps it to a clean exit code, and callers can branch. + assert ei.value.details["replay_id"] == _signed().replay_id + assert ei.value.details["format"] == "non-rrweb" + + +# ============================================================================= +# discover() (with mocked query_fn) +# ============================================================================= + + +class TestDiscoverNoQueryFn: + """Without query_fn, discover raises a clear RuntimeError.""" + + def test_raises_without_query_fn(self) -> None: + """discover delegates to query_fn; missing dependency must fail loudly.""" + api = _mock_api_client() + service = ReplaysService(api) # no query_fn + with pytest.raises(RuntimeError, match="query_fn"): + service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + + def test_empty_replay_ids_returns_empty(self) -> None: + """No distinct_id and empty replay_ids returns [] without calling query_fn.""" + api = _mock_api_client() + query_fn = MagicMock() + service = ReplaysService(api, query_fn=query_fn) + result = service.discover(replay_ids=[]) + assert result == [] + query_fn.assert_not_called() + + +# ============================================================================= +# discover() / events_for() parsing — against the REAL Insights `series` shape +# +# These mock `result.series` with the nested-dict structure the live Insights +# API actually returns (captured from project 3), NOT a hand-built `.df`. The +# old tests mocked a `.df` with property-named columns — a shape the API never +# produces — which is exactly why the silent-empty bug shipped green. +# ============================================================================= + + +def _series_result(series: dict[str, Any]) -> MagicMock: + """A stand-in Workspace.query() result exposing only `.series`.""" + result = MagicMock() + result.series = series + return result + + +# Discovery: $mp_session_record grouped by [replay_id, retention] with +# math="min", math_property="time". Leaf `all` is the min event-time in unix +# SECONDS. Each replay carries an `$overall` rollup sibling that must be skipped. +_DISCOVERY_SERIES: dict[str, Any] = { + "Session Recording Checkpoint [Minimum Time]": { + "$overall": {"all": 1779319127}, + "rid-aaa": { + "$overall": {"all": 1779322882}, + "30": {"all": 1779322882}, + }, + "rid-bbb": { + "$overall": {"all": 1779332317}, + "7": {"all": 1779332317}, + }, + } +} + +# Older SDK: the replay has no `$mp_replay_retention_period`, so its only child +# is the `$overall` rollup. Parser must default retention to 30 + warn, and +# still recover start_time from the `$overall` branch. +_DISCOVERY_SERIES_NO_RETENTION: dict[str, Any] = { + "Session Recording Checkpoint [Minimum Time]": { + "$overall": {"all": 1779319127}, + "rid-old": {"$overall": {"all": 1779319127}}, + } +} + +# events_for: $all_events grouped by [$time, $event_name, $mp_replay_id]. $time +# keys are second-precision ISO strings; leaf `all` is the event count. +_EVENTS_SERIES: dict[str, Any] = { + "All Events [Total Events]": { + "$overall": {"all": 13}, + "2026-05-21T16:31:31": { + "$overall": {"all": 1}, + "$mp_dead_click": { + "$overall": {"all": 1}, + "rid-bab": {"all": 1}, + }, + }, + "2026-05-21T16:31:25": { + "$overall": {"all": 12}, + "Browser API fetch": { + "$overall": {"all": 12}, + "rid-bab": {"all": 12}, + }, + }, + } +} + +# events_for with one extra group key ($browser) → one deeper nesting level. +_EVENTS_SERIES_WITH_PROP: dict[str, Any] = { + "All Events [Total Events]": { + "$overall": {"all": 1}, + "2026-05-21T16:31:25": { + "$overall": {"all": 1}, + "Browser API fetch": { + "$overall": {"all": 1}, + "rid-bab": { + "$overall": {"all": 1}, + "Chrome": {"all": 1}, + }, + }, + }, + } +} + + +class TestDiscoverParsing: + """discover() parses the real min-time `series` into ReplaySummary rows.""" + + def test_one_summary_per_replay(self) -> None: + """Two replays in the series → two summaries, correct retention + start_time.""" + api = _mock_api_client(project_id="3") + query_fn = MagicMock(return_value=_series_result(_DISCOVERY_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + out = service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + by_id = {s.replay_id: s for s in out} + assert set(by_id) == {"rid-aaa", "rid-bbb"} + assert by_id["rid-aaa"].retention_days == 30 + assert by_id["rid-bbb"].retention_days == 7 + # Leaf is unix seconds; start_time is unix ms. + assert by_id["rid-aaa"].start_time == 1779322882 * 1000 + assert by_id["rid-aaa"].distinct_id == "u-1" + assert by_id["rid-aaa"].project_id == 3 + + def test_query_uses_min_time_aggregation(self) -> None: + """discover asks Insights for min(time) grouped by replay_id + retention.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_DISCOVERY_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + _args, kwargs = query_fn.call_args + assert kwargs["math"] == "min" + assert kwargs["math_property"] == "$time" + assert kwargs["group_by"] == [ + "$mp_replay_id", + "$mp_replay_retention_period", + ] + + def test_missing_retention_defaults_30_with_warning(self) -> None: + """A replay whose only child is `$overall` → retention 30 + UserWarning.""" + api = _mock_api_client() + query_fn = MagicMock( + return_value=_series_result(_DISCOVERY_SERIES_NO_RETENTION) + ) + service = ReplaysService(api, query_fn=query_fn) + with pytest.warns(UserWarning, match=r"\$mp_replay_retention_period"): + out = service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + assert len(out) == 1 + assert out[0].retention_days == 30 + assert out[0].start_time == 1779319127 * 1000 + + def test_empty_series_returns_empty(self) -> None: + """An empty series yields no summaries and does not raise.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result({})) + service = ReplaysService(api, query_fn=query_fn) + assert ( + service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + == [] + ) + + def test_nonstandard_retention_defaults_30_with_warning(self) -> None: + """A retention key outside {1,7,30,90} → default 30 + warn, time kept.""" + api = _mock_api_client() + series: dict[str, Any] = { + "Session Recording Checkpoint [Minimum Time]": { + "$overall": {"all": 1779319127}, + "rid-weird": { + "$overall": {"all": 1779322882}, + "15": {"all": 1779322882}, + }, + } + } + query_fn = MagicMock(return_value=_series_result(series)) + service = ReplaysService(api, query_fn=query_fn) + with pytest.warns(UserWarning, match=r"\$mp_replay_retention_period"): + out = service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + assert len(out) == 1 + assert out[0].retention_days == 30 + assert out[0].start_time == 1779322882 * 1000 + + def test_limit_caps_summaries(self) -> None: + """limit truncates the returned summaries.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_DISCOVERY_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + out = service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27", limit=1 + ) + assert len(out) == 1 + + def test_default_window_is_90_day_lookback(self) -> None: + """Dateless replay_ids discovery uses last=90 (covers max retention). + + The _resolve_retention path hydrates explicit IDs with no window; + Workspace.query's last=30 default would silently miss replays 31–90 + days old, defaulting their retention to 30 and breaking the CDN walk. + """ + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result({})) + service = ReplaysService(api, query_fn=query_fn) + service.discover(replay_ids=["rid-aaa"]) + _args, kwargs = query_fn.call_args + assert kwargs.get("last") == 90 + assert "from_date" not in kwargs + assert "to_date" not in kwargs + + def test_explicit_window_overrides_lookback(self) -> None: + """An explicit from/to is passed through; last is not sent.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_DISCOVERY_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + _args, kwargs = query_fn.call_args + assert kwargs.get("from_date") == "2026-05-20" + assert kwargs.get("to_date") == "2026-05-27" + assert "last" not in kwargs + + def test_missing_retention_warning_has_no_doubled_prefix(self) -> None: + """The warning text must not carry a literal 'UserWarning:' prefix. + + warnings.warn(..., UserWarning) prepends the category name itself; + embedding it in the message too renders 'UserWarning: UserWarning: …'. + """ + api = _mock_api_client() + query_fn = MagicMock( + return_value=_series_result(_DISCOVERY_SERIES_NO_RETENTION) + ) + service = ReplaysService(api, query_fn=query_fn) + with pytest.warns(UserWarning) as record: + service.discover( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + message = str(record[0].message) + assert not message.startswith("UserWarning:") + assert message.startswith("replay ") + + +class TestEventsForParsing: + """events_for() parses the real $all_events `series` into ReplayEvent rows.""" + + def test_returns_time_sorted_events_per_replay(self) -> None: + """Two events for one replay come back time-sorted.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_EVENTS_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + out = service.events_for(["rid-bab"]) + assert set(out) == {"rid-bab"} + events = out["rid-bab"] + assert [e.event_name for e in events] == ["Browser API fetch", "$mp_dead_click"] + assert events[0].event_time < events[1].event_time + + def test_event_properties_surface(self) -> None: + """An extra group key lands in ReplayEvent.properties.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_EVENTS_SERIES_WITH_PROP)) + service = ReplaysService(api, query_fn=query_fn) + out = service.events_for(["rid-bab"], event_properties=["$browser"]) + assert out["rid-bab"][0].properties == {"$browser": "Chrome"} + + def test_issues_all_events_query_shape(self) -> None: + """events_for queries $all_events grouped by time/event_name/replay_id.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result(_EVENTS_SERIES)) + service = ReplaysService(api, query_fn=query_fn) + service.events_for(["rid-bab"]) + args, kwargs = query_fn.call_args + assert args[0] == "$all_events" + assert kwargs["group_by"][:3] == ["$time", "$event_name", "$mp_replay_id"] + + def test_empty_series_returns_empty_dict(self) -> None: + """An empty series yields an empty dict, no raise.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result({})) + service = ReplaysService(api, query_fn=query_fn) + assert service.events_for(["rid-bab"]) == {} + + def test_default_window_is_90_day_lookback(self) -> None: + """Without an explicit window, the query uses last=90 (max retention). + + The underlying Workspace.query default is last=30, which would silently + miss events for replays 31–90 days old. + """ + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result({})) + service = ReplaysService(api, query_fn=query_fn) + service.events_for(["rid-bab"]) + _args, kwargs = query_fn.call_args + assert kwargs.get("last") == 90 + assert "from_date" not in kwargs + assert "to_date" not in kwargs + + def test_explicit_window_overrides_lookback(self) -> None: + """An explicit from/to is passed through; last is not sent.""" + api = _mock_api_client() + query_fn = MagicMock(return_value=_series_result({})) + service = ReplaysService(api, query_fn=query_fn) + service.events_for(["rid-bab"], from_date="2026-05-20", to_date="2026-05-21") + _args, kwargs = query_fn.call_args + assert kwargs.get("from_date") == "2026-05-20" + assert kwargs.get("to_date") == "2026-05-21" + assert "last" not in kwargs + + +# Ensure the module is importable as a package for pytest collection. +_ = json diff --git a/tests/unit/cli/test_replays_cli.py b/tests/unit/cli/test_replays_cli.py new file mode 100644 index 00000000..0d935274 --- /dev/null +++ b/tests/unit/cli/test_replays_cli.py @@ -0,0 +1,648 @@ +# ruff: noqa: ARG001, ARG005 +"""Tests for `mp replays` CLI commands (044-session-replay). + +Coverage: +- ``mp replays list`` happy path + empty result + --help +- ``mp replays events`` happy path + --properties cap (exits 3) +- ``mp replays sign`` masking by default + --reveal-signed-urls full disclosure + + stderr warning on every --reveal-signed-urls invocation +- ``mp replays fetch`` -o file output + one-line summary without -o +- Exit code mapping for SessionReplayAccessError (2) and ReplayNotFoundError (4) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import typer.testing + +from mixpanel_headless.cli.main import app +from mixpanel_headless.exceptions import ( + ReplayNotFoundError, + SessionReplayAccessError, + SignedURLExpiredError, + UnsupportedReplayFormatError, +) +from mixpanel_headless.types import Replay, ReplayEvent, ReplaySummary, SignedReplay + +runner = typer.testing.CliRunner() + + +# ============================================================================= +# Fixture helpers +# ============================================================================= + + +def _summary(replay_id: str = "r-19221") -> ReplaySummary: + """Build a ReplaySummary for mocking workspace.list_replays output.""" + return ReplaySummary( + replay_id=replay_id, + distinct_id="user-42", + project_id=12345, + start_time=1716810000000, + retention_days=30, + ) + + +def _signed(replay_id: str = "r-19221") -> SignedReplay: + """Build a SignedReplay for mocking workspace.sign_replays output.""" + return SignedReplay( + replay_id=replay_id, + url="https://cdn.test/srr-us/sha-12345/", + query_string=("URLPrefix=ABCDEFGH&Expires=1716810300&KeyName=K&Signature=zzzz"), + env="prod", + signed_at=1716810000.0, + ) + + +def _replay_event(replay_id: str = "r-19221") -> ReplayEvent: + """Build a ReplayEvent for mocking workspace.events_for_replay output.""" + return ReplayEvent( + replay_id=replay_id, + event_name="Login", + event_time=1716810000, + properties={"$browser": "Chrome"}, + ) + + +def _replay(replay_id: str = "r-19221") -> Replay: + """Build a Replay for mocking workspace.fetch_replay output.""" + return Replay( + replay_id=replay_id, + distinct_id="user-42", + project_id=12345, + start_time=1716810000000, + end_time=1716810060000, # 60s long + retention_days=30, + rrweb_events=[ + {"type": 4, "data": {"href": "/"}, "timestamp": 1716810000000}, + {"type": 3, "data": {}, "timestamp": 1716810030000}, + {"type": 4, "data": {"href": "/x"}, "timestamp": 1716810060000}, + ], + ) + + +# ============================================================================= +# mp replays --help / list --help +# ============================================================================= + + +class TestReplaysHelp: + """The replays group and its subcommands are discoverable via --help.""" + + def test_replays_group_help(self) -> None: + """`mp replays --help` lists the replay subcommands.""" + result = runner.invoke(app, ["replays", "--help"]) + assert result.exit_code == 0 + for sub in ("list", "events", "sign", "fetch", "analyze", "for-user"): + assert sub in result.stdout + + def test_list_help_documents_flags(self) -> None: + """`mp replays list --help` documents --user, --from, --to.""" + result = runner.invoke(app, ["replays", "list", "--help"]) + assert result.exit_code == 0 + for flag in ("--user", "--replay-id", "--from", "--to", "--limit"): + assert flag in result.stdout + + def test_sign_help_documents_reveal_flag(self) -> None: + """`mp replays sign --help` documents --reveal-signed-urls.""" + result = runner.invoke(app, ["replays", "sign", "--help"]) + assert result.exit_code == 0 + assert "--reveal-signed-urls" in result.stdout + + +# ============================================================================= +# mp replays list +# ============================================================================= + + +class TestReplaysList: + """`mp replays list` happy path + empty result.""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_list_returns_json_array(self, mock_get_ws: MagicMock) -> None: + """Happy path returns a JSON array of summaries.""" + mock_ws = MagicMock() + mock_ws.list_replays.return_value = [_summary("r-1"), _summary("r-2")] + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "list", + "--user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert len(data) == 2 + assert data[0]["replay_id"] == "r-1" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_empty_result_exits_0(self, mock_get_ws: MagicMock) -> None: + """Empty result is exit 0 with an empty JSON array (not an error).""" + mock_ws = MagicMock() + mock_ws.list_replays.return_value = [] + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "list", + "--user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + ], + ) + assert result.exit_code == 0 + assert json.loads(result.stdout) == [] + + +# ============================================================================= +# mp replays events +# ============================================================================= + + +class TestReplaysEvents: + """`mp replays events` happy path + property cap (exit 3).""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_events_returns_json(self, mock_get_ws: MagicMock) -> None: + """Happy path returns a JSON array of ReplayEvent dicts.""" + mock_ws = MagicMock() + mock_ws.events_for_replay.return_value = [_replay_event()] + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "events", "r-19221"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data[0]["event_name"] == "Login" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_events_passes_date_window(self, mock_get_ws: MagicMock) -> None: + """`--from` / `--to` are threaded into events_for_replay's window.""" + mock_ws = MagicMock() + mock_ws.events_for_replay.return_value = [_replay_event()] + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "events", + "r-19221", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + ], + ) + assert result.exit_code == 0 + kwargs = mock_ws.events_for_replay.call_args.kwargs + assert kwargs["from_date"] == "2026-05-20" + assert kwargs["to_date"] == "2026-05-27" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_too_many_properties_exits_3(self, mock_get_ws: MagicMock) -> None: + """Passing >5 properties emits the canonical message and exits 3.""" + # The CLI validates the group-by cap before touching the workspace + # (contracts/error-messages.md §4), so events_for_replay is never + # reached for an over-cap request. + mock_ws = MagicMock() + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "events", + "r-19221", + "--properties", + "a,b,c,d,e,f", + ], + ) + assert result.exit_code == 3 + assert "too many event properties" in result.stderr + assert "max is 5" in result.stderr + mock_ws.events_for_replay.assert_not_called() + + +# ============================================================================= +# mp replays sign — redaction + --reveal-signed-urls +# ============================================================================= + + +class TestReplaysSignRedaction: + """Default output masks; --reveal-signed-urls discloses + warns.""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_default_masks_query_string(self, mock_get_ws: MagicMock) -> None: + """Default JSON output has '' for query_string.""" + mock_ws = MagicMock() + mock_ws.sign_replays.return_value = [_signed()] + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "sign", "r-19221"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert " None: + """--reveal-signed-urls includes the bearer credential verbatim.""" + mock_ws = MagicMock() + signed = _signed() + mock_ws.sign_replays.return_value = [signed] + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + ["replays", "sign", "r-19221", "--reveal-signed-urls"], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data[0]["query_string"] == signed.query_string + # to_dict() includes the _warning key per data-model.md §2.2. + assert "_warning" in data[0] + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_reveal_emits_stderr_warning(self, mock_get_ws: MagicMock) -> None: + """Every --reveal-signed-urls invocation prints the warning to stderr.""" + mock_ws = MagicMock() + mock_ws.sign_replays.return_value = [_signed()] + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + ["replays", "sign", "r-19221", "--reveal-signed-urls"], + ) + assert result.exit_code == 0 + assert "bearer credentials" in result.stderr + assert "5 minutes" in result.stderr + + +# ============================================================================= +# mp replays fetch — file output + one-line summary +# ============================================================================= + + +class TestReplaysFetch: + """`-o file.json` writes JSON array; without -o prints one-line summary.""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_fetch_with_output_writes_file( + self, mock_get_ws: MagicMock, tmp_path: Path + ) -> None: + """`-o file.json` writes a JSON array of timestamp-sorted rrweb events.""" + mock_ws = MagicMock() + mock_ws.fetch_replay.return_value = _replay() + mock_get_ws.return_value = mock_ws + + out_path = tmp_path / "replay.json" + result = runner.invoke( + app, ["replays", "fetch", "r-19221", "-o", str(out_path)] + ) + assert result.exit_code == 0 + assert out_path.exists() + data = json.loads(out_path.read_text()) + assert isinstance(data, list) + # Timestamps ascending. + ts = [int(e["timestamp"]) for e in data] + assert ts == sorted(ts) + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_fetch_without_output_prints_summary(self, mock_get_ws: MagicMock) -> None: + """Without -o, prints a one-line summary to stdout.""" + mock_ws = MagicMock() + mock_ws.fetch_replay.return_value = _replay() + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "fetch", "r-19221"]) + assert result.exit_code == 0 + assert "fetched r-19221" in result.stdout + assert "3 events" in result.stdout + assert "30-day retention" in result.stdout + + +# ============================================================================= +# Exit-code mapping for new exceptions +# ============================================================================= + + +class TestExitCodeMapping: + """Replay exceptions map to stable CLI exit codes (2 / 4 / 1).""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_sensitive_data_access_exits_2(self, mock_get_ws: MagicMock) -> None: + """SessionReplayAccessError maps to exit code 2 (AUTH_ERROR).""" + mock_ws = MagicMock() + mock_ws.sign_replays.side_effect = SessionReplayAccessError( + ("Project 3713224 has SESSION_RECORDING_SENSITIVE_DATA enabled."), + details={ + "project_id": 3713224, + "flag": "SESSION_RECORDING_SENSITIVE_DATA", + "permission_required": "sensitive_data_replay", + }, + status_code=403, + ) + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "sign", "r-19221"]) + assert result.exit_code == 2 + assert "sensitive replay data" in result.stderr + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_analyze_prints_markdown(self, mock_get_ws: MagicMock) -> None: + """`mp replays analyze` (default) routes through Workspace.analyze_replay.""" + mock_ws = MagicMock() + mock_ws.analyze_replay.return_value = _replay().summary_markdown + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "analyze", "r-19221"]) + assert result.exit_code == 0 + assert "no actions extracted" in result.stdout + # The default path uses the analyze_replay sugar, not a bare fetch. + mock_ws.analyze_replay.assert_called_once_with("r-19221") + mock_ws.fetch_replay.assert_not_called() + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_analyze_json_uses_fetch_replay(self, mock_get_ws: MagicMock) -> None: + """`--format json` needs the structured actions, so it fetches the Replay.""" + mock_ws = MagicMock() + mock_ws.fetch_replay.return_value = _replay() + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, ["replays", "analyze", "r-19221", "--format", "json"] + ) + assert result.exit_code == 0 + assert isinstance(json.loads(result.stdout), list) + mock_ws.fetch_replay.assert_called_once() + mock_ws.analyze_replay.assert_not_called() + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_for_user_writes_to_out_dir( + self, mock_get_ws: MagicMock, tmp_path: Path + ) -> None: + """`mp replays for-user --out-dir DIR` writes index.json + per-replay md.""" + from mixpanel_headless.types import ReplayBundle + + bundle = ReplayBundle( + replays=[_replay("r-1"), _replay("r-2")], + computed_at="2026-05-27T00:00:00Z", + project_id=12345, + ) + mock_ws = MagicMock() + mock_ws.replays_for_user.return_value = bundle + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "for-user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + "--include", + "analyze", + "--out-dir", + str(tmp_path), + ], + ) + assert result.exit_code == 0 + assert (tmp_path / "index.json").exists() + assert (tmp_path / "r-1-summary.md").exists() + assert (tmp_path / "r-2-summary.md").exists() + assert "wrote 2 replays" in result.stdout + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_for_user_includes_mixpanel_events_by_default( + self, mock_get_ws: MagicMock + ) -> None: + """Bare `for-user` mirrors the Python API default: events ON.""" + from mixpanel_headless.types import ReplayBundle + + bundle = ReplayBundle( + replays=[_replay("r-1")], + computed_at="2026-05-27T00:00:00Z", + project_id=12345, + ) + mock_ws = MagicMock() + mock_ws.replays_for_user.return_value = bundle + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "for-user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + ], + ) + assert result.exit_code == 0 + kwargs = mock_ws.replays_for_user.call_args.kwargs + assert kwargs["include_mixpanel_events"] is True + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_for_user_no_mixpanel_events_opts_out(self, mock_get_ws: MagicMock) -> None: + """`--no-mixpanel-events` turns the Mixpanel-events join off.""" + from mixpanel_headless.types import ReplayBundle + + bundle = ReplayBundle( + replays=[_replay("r-1")], + computed_at="2026-05-27T00:00:00Z", + project_id=12345, + ) + mock_ws = MagicMock() + mock_ws.replays_for_user.return_value = bundle + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "for-user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + "--no-mixpanel-events", + ], + ) + assert result.exit_code == 0 + kwargs = mock_ws.replays_for_user.call_args.kwargs + assert kwargs["include_mixpanel_events"] is False + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_replay_not_found_exits_4(self, mock_get_ws: MagicMock) -> None: + """ReplayNotFoundError maps to exit code 4 (NOT_FOUND).""" + mock_ws = MagicMock() + mock_ws.fetch_replay.side_effect = ReplayNotFoundError( + "Replay r-19221 not found on CDN.", + details={ + "replay_id": "r-19221", + "retention_days": 30, + "cdn_url_prefix": "https://cdn.test/srr-us/sha/", + }, + status_code=404, + ) + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "fetch", "r-19221"]) + assert result.exit_code == 4 + assert "not found" in result.stderr + assert "r-19221" in result.stderr + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_signed_url_expired_exits_1(self, mock_get_ws: MagicMock) -> None: + """SignedURLExpiredError maps to the canonical message + exit 1.""" + mock_ws = MagicMock() + mock_ws.fetch_replay.side_effect = SignedURLExpiredError( + "Signed URL for replay r-19221 expired (5-minute TTL).", + details={ + "replay_id": "r-19221", + "signed_at": 1716810000.0, + "expired_at": 1716810300.0, + }, + status_code=403, + ) + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "fetch", "r-19221"]) + assert result.exit_code == 1 + assert "signed URL expired (5-minute TTL)" in result.stderr + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_unsupported_format_exits_1_without_traceback( + self, mock_get_ws: MagicMock + ) -> None: + """A mobile (non-rrweb) replay yields a clean message + exit 1, not a traceback.""" + mock_ws = MagicMock() + mock_ws.fetch_replay.side_effect = UnsupportedReplayFormatError( + "Replay r-19221 appears to be a mobile session (non-rrweb format). " + "Mobile session replays are not yet supported by mixpanel-headless. " + "Track upstream at SR-230.", + details={"replay_id": "r-19221", "format": "non-rrweb"}, + ) + mock_get_ws.return_value = mock_ws + + result = runner.invoke(app, ["replays", "fetch", "r-19221"]) + assert result.exit_code == 1 + # Curated one-liner on stderr (the handler ran)... + assert "mobile session (non-rrweb format)" in result.stderr + assert "r-19221" in result.stderr + # ...and the exception did NOT leak as an uncaught traceback. + assert not isinstance(result.exception, UnsupportedReplayFormatError) + + +# ============================================================================= +# mp replays for-user — --include validation +# ============================================================================= + + +class TestForUserIncludeValidation: + """`--include` rejects unsupported values instead of silently ignoring them.""" + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_unsupported_include_value_is_rejected( + self, mock_get_ws: MagicMock + ) -> None: + """A typo like `--include analyse` fails fast with a usage error. + + Regression guard: unknown ``--include`` values used to be dropped + silently, so the command appeared to succeed while doing something + other than what was asked. It now raises ``typer.BadParameter`` + (exit 2) and never reaches workspace resolution. + """ + result = runner.invoke( + app, + [ + "replays", + "for-user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + "--include", + "analyse", # typo for "analyze" + ], + ) + # typer.BadParameter is a Click UsageError → exit code 2. + assert result.exit_code == 2 + # The offending value is echoed back so the user can spot the typo. + assert "analyse" in result.stderr + # Validation happens before any workspace work (fail fast). + mock_get_ws.assert_not_called() + + @patch("mixpanel_headless.cli.commands.replays.get_workspace") + def test_supported_include_value_is_accepted(self, mock_get_ws: MagicMock) -> None: + """The one supported value, 'analyze', passes validation as before.""" + from mixpanel_headless.types import ReplayBundle + + bundle = ReplayBundle( + replays=[_replay("r-1")], + computed_at="2026-05-27T00:00:00Z", + project_id=12345, + ) + mock_ws = MagicMock() + mock_ws.replays_for_user.return_value = bundle + mock_get_ws.return_value = mock_ws + + result = runner.invoke( + app, + [ + "replays", + "for-user", + "user-42", + "--from", + "2026-05-20", + "--to", + "2026-05-27", + "--include", + "analyze", + ], + ) + assert result.exit_code == 0 + mock_get_ws.assert_called_once() + + +# ============================================================================= +# mp replays sign / fetch — --env Literal validation +# ============================================================================= + + +class TestEnvValidation: + """`--env` is a Literal choice validated by Typer, not a manual guard.""" + + def test_sign_rejects_unknown_env(self) -> None: + """`sign --env staging` is rejected by Typer's choice validation (exit 2).""" + result = runner.invoke(app, ["replays", "sign", "r-1", "--env", "staging"]) + assert result.exit_code == 2 + + def test_fetch_rejects_unknown_env(self) -> None: + """`fetch --env staging` is likewise rejected before any workspace work.""" + result = runner.invoke(app, ["replays", "fetch", "r-1", "--env", "staging"]) + assert result.exit_code == 2 diff --git a/tests/unit/test_exceptions_session_replay.py b/tests/unit/test_exceptions_session_replay.py new file mode 100644 index 00000000..d8f445a5 --- /dev/null +++ b/tests/unit/test_exceptions_session_replay.py @@ -0,0 +1,249 @@ +"""Unit tests for the session-replay exception hierarchy (044-session-replay).""" + +from __future__ import annotations + +import json + +import pytest + +from mixpanel_headless.exceptions import ( + APIError, + ReplayNotFoundError, + SessionReplayAccessError, + SessionReplayError, + SignedURLExpiredError, + UnsupportedReplayFormatError, +) + + +class TestSessionReplayHierarchy: + """Every new replay exception subclasses APIError via SessionReplayError.""" + + @pytest.mark.parametrize( + "exc_cls", + [ + SessionReplayAccessError, + SignedURLExpiredError, + ReplayNotFoundError, + UnsupportedReplayFormatError, + ], + ) + def test_subclass_via_session_replay_error( + self, exc_cls: type[SessionReplayError] + ) -> None: + """Each leaf class is a SessionReplayError and an APIError.""" + assert issubclass(exc_cls, SessionReplayError) + assert issubclass(exc_cls, APIError) + + +class TestSessionReplayAccessError: + """403 + SESSION_RECORDING_SENSITIVE_DATA mapping (error-messages.md §1).""" + + def _build(self) -> SessionReplayAccessError: + """Construct an instance with the canonical message + details.""" + project_id = 3713224 + message = ( + f"Project {project_id} has SESSION_RECORDING_SENSITIVE_DATA enabled. " + f"Your account lacks sensitive-data access. Contact the project owner " + f"to grant the 'sensitive_data_replay' permission, or use a service " + f"account that has it." + ) + return SessionReplayAccessError( + message, + details={ + "project_id": project_id, + "flag": "SESSION_RECORDING_SENSITIVE_DATA", + "permission_required": "sensitive_data_replay", + }, + status_code=403, + ) + + def test_canonical_message_verbatim(self) -> None: + """Message matches the catalog in error-messages.md §1 verbatim.""" + exc = self._build() + assert "has SESSION_RECORDING_SENSITIVE_DATA enabled" in str(exc) + assert "lacks sensitive-data access" in str(exc) + assert "'sensitive_data_replay'" in str(exc) + assert "service account" in str(exc) + + def test_status_code(self) -> None: + """status_code is exposed via the APIError property.""" + assert self._build().status_code == 403 + + def test_details_preserved(self) -> None: + """details keys survive `.details` access.""" + exc = self._build() + assert exc.details["project_id"] == 3713224 + assert exc.details["flag"] == "SESSION_RECORDING_SENSITIVE_DATA" + assert exc.details["permission_required"] == "sensitive_data_replay" + + def test_to_dict_round_trip(self) -> None: + """details keys survive a to_dict() round-trip and are JSON-serializable.""" + exc = self._build() + d = exc.to_dict() + assert d["details"]["project_id"] == 3713224 + assert d["details"]["flag"] == "SESSION_RECORDING_SENSITIVE_DATA" + assert d["details"]["permission_required"] == "sensitive_data_replay" + # Ensure the whole dict is JSON-serializable. + json.dumps(d) + + +class TestSignedURLExpiredError: + """Signed-URL 5-min TTL expiry (error-messages.md §2).""" + + def _build(self) -> SignedURLExpiredError: + """Construct an instance with the canonical message + details.""" + replay_id = "r-19221" + message = ( + f"Signed URL for replay {replay_id} expired (5-minute TTL). " + f"Re-sign with sign_replay({replay_id!r}) or use the default " + f"re_sign_on_expiry=True on stream_replay." + ) + return SignedURLExpiredError( + message, + details={ + "replay_id": replay_id, + "signed_at": 1716810000.0, + "expired_at": 1716810300.0, + }, + status_code=403, + ) + + def test_canonical_message_verbatim(self) -> None: + """Message matches the catalog in error-messages.md §2 verbatim.""" + exc = self._build() + assert "Signed URL for replay r-19221 expired (5-minute TTL)" in str(exc) + assert "Re-sign with sign_replay('r-19221')" in str(exc) + assert "re_sign_on_expiry=True" in str(exc) + + def test_status_code(self) -> None: + """status_code is exposed via the APIError property.""" + assert self._build().status_code == 403 + + def test_details_round_trip(self) -> None: + """details keys survive a to_dict() round-trip.""" + d = self._build().to_dict() + assert d["details"]["replay_id"] == "r-19221" + assert d["details"]["signed_at"] == 1716810000.0 + assert d["details"]["expired_at"] == 1716810300.0 + json.dumps(d) + + +class TestReplayNotFoundError: + """404 on first CDN file (error-messages.md §3).""" + + def _build(self) -> ReplayNotFoundError: + """Construct an instance with the canonical message + details.""" + replay_id = "r-19221" + retention_days = 30 + url_prefix = "https://cdn.mxpnl.com/srr-us/abc123-3713224/" + message = ( + f"Replay {replay_id} not found on CDN. The replay may have aged out " + f"of its retention window ({retention_days} days), never been recorded, " + f"or been deleted." + ) + return ReplayNotFoundError( + message, + details={ + "replay_id": replay_id, + "retention_days": retention_days, + "cdn_url_prefix": url_prefix, + }, + status_code=404, + ) + + def test_canonical_message_verbatim(self) -> None: + """Message matches the catalog in error-messages.md §3 verbatim.""" + exc = self._build() + assert "not found on CDN" in str(exc) + assert "aged out of its retention window (30 days)" in str(exc) + assert "never been recorded" in str(exc) + + def test_status_code(self) -> None: + """status_code is exposed via the APIError property.""" + assert self._build().status_code == 404 + + def test_details_round_trip(self) -> None: + """details keys survive a to_dict() round-trip.""" + d = self._build().to_dict() + assert d["details"]["replay_id"] == "r-19221" + assert d["details"]["retention_days"] == 30 + assert ( + d["details"]["cdn_url_prefix"] + == "https://cdn.mxpnl.com/srr-us/abc123-3713224/" + ) + json.dumps(d) + + +class TestUnsupportedReplayFormatError: + """Non-rrweb (mobile) replay bytes (error-messages.md §9).""" + + def _build(self) -> UnsupportedReplayFormatError: + """Construct an instance with the canonical message + details.""" + replay_id = "r-19221" + message = ( + f"Replay {replay_id} appears to be a mobile session (non-rrweb " + f"format). Mobile session replays are not yet supported by " + f"mixpanel-headless. Track upstream at SR-230." + ) + return UnsupportedReplayFormatError( + message, + details={"replay_id": replay_id, "format": "non-rrweb"}, + ) + + def test_canonical_message_verbatim(self) -> None: + """Message matches the catalog in error-messages.md §9 verbatim.""" + exc = self._build() + assert "appears to be a mobile session (non-rrweb format)" in str(exc) + assert "not yet supported by mixpanel-headless" in str(exc) + assert "SR-230" in str(exc) + + def test_status_code_defaults_to_501(self) -> None: + """status_code defaults to 501 (Not Implemented) — no HTTP failure occurred.""" + assert self._build().status_code == 501 + + def test_details_round_trip(self) -> None: + """details keys survive a to_dict() round-trip and are JSON-serializable.""" + d = self._build().to_dict() + assert d["details"]["replay_id"] == "r-19221" + assert d["details"]["format"] == "non-rrweb" + json.dumps(d) + + +class TestCatchByBaseClass: + """Callers can catch any session-replay error with the base class.""" + + def test_catch_access_error_as_session_replay_error(self) -> None: + """SessionReplayAccessError raises as SessionReplayError.""" + with pytest.raises(SessionReplayError): + raise SessionReplayAccessError( + "msg", + details={"project_id": 1}, + status_code=403, + ) + + def test_catch_expired_error_as_session_replay_error(self) -> None: + """SignedURLExpiredError raises as SessionReplayError.""" + with pytest.raises(SessionReplayError): + raise SignedURLExpiredError("msg", status_code=403) + + def test_catch_not_found_as_session_replay_error(self) -> None: + """ReplayNotFoundError raises as SessionReplayError.""" + with pytest.raises(SessionReplayError): + raise ReplayNotFoundError("msg", status_code=404) + + def test_catch_unsupported_format_as_session_replay_error(self) -> None: + """UnsupportedReplayFormatError raises as SessionReplayError.""" + with pytest.raises(SessionReplayError): + raise UnsupportedReplayFormatError( + "msg", details={"replay_id": "r-1", "format": "non-rrweb"} + ) + + def test_catch_session_replay_error_as_api_error(self) -> None: + """SessionReplayError raises as APIError (so existing handlers work).""" + with pytest.raises(APIError): + raise SessionReplayAccessError( + "msg", + details={"project_id": 1}, + status_code=403, + ) diff --git a/tests/unit/test_replay_bundle.py b/tests/unit/test_replay_bundle.py new file mode 100644 index 00000000..d90d8928 --- /dev/null +++ b/tests/unit/test_replay_bundle.py @@ -0,0 +1,474 @@ +"""Combined verification: analyzer + labels + aggregators + ReplayBundle. + +Tests are organized by component but stay lean — exercising the public +contracts documented in data-model.md §2.6 and contracts/python-api.md §4. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from mixpanel_headless._internal.replays.aggregators import ( + long_pauses, + rage_clicks, + top_clicks, +) +from mixpanel_headless._internal.replays.rrweb_analyzer import RrwebAnalyzer +from mixpanel_headless.replay_labels import ( + default_label_fn, + selector_label_fn, + url_normalizer, +) +from mixpanel_headless.types import ( + Replay, + ReplayBundle, + UserAction, +) + +_FIXTURE_001 = Path("tests/fixtures/rrweb/sample-replay-001.json") + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _load_sample(path: Path = _FIXTURE_001) -> list[dict[str, Any]]: + """Load the hand-built sample rrweb stream for analyzer tests.""" + with path.open() as f: + events = json.load(f) + return events # type: ignore[no-any-return] + + +def _build_action( + timestamp: int = 1716810000000, + action: str = "click", + target_desc: str = "button", + url: str | None = "https://example.com/login", + metadata: dict[str, Any] | None = None, +) -> UserAction: + """Construct a UserAction for label / aggregator tests.""" + return UserAction( + timestamp=timestamp, + action=action, # type: ignore[arg-type] + target_node_id=1, + target_desc=target_desc, + url=url, + metadata=metadata or {}, + ) + + +def _make_replay(replay_id: str, actions: list[UserAction]) -> Replay: + """Build a Replay with the given actions for bundle tests.""" + if actions: + start = min(a.timestamp for a in actions) + end = max(a.timestamp for a in actions) + else: + start = 1716810000000 + end = 1716810060000 + return Replay( + replay_id=replay_id, + distinct_id=None, + project_id=12345, + start_time=start, + end_time=max(end, start), + retention_days=30, + rrweb_events=[ + {"type": 4, "data": {"href": a.url}, "timestamp": a.timestamp} + for a in actions + if a.action == "navigate" + ] + or [{"type": 4, "data": {"href": "/"}, "timestamp": start}], + actions=actions, + mixpanel_events=[], + ) + + +# ============================================================================= +# Labels +# ============================================================================= + + +class TestUrlNormalizer: + """url_normalizer collapses parameterized URLs.""" + + def test_strips_query_string(self) -> None: + """Query strings disappear.""" + assert url_normalizer("/x?a=1&b=2") == "/x" + + def test_replaces_numeric_segments(self) -> None: + """Numeric path segments become :id.""" + assert url_normalizer("/users/12345/profile") == "/users/:id/profile" + + def test_preserves_host(self) -> None: + """Host portion stays when the URL is absolute.""" + out = url_normalizer("https://app.example.com/users/12345/profile?ref=x") + assert out == "https://app.example.com/users/:id/profile" + + def test_empty_url(self) -> None: + """Empty input returns empty (no crash).""" + assert url_normalizer("") == "" + + +class TestDefaultLabelFn: + """default_label_fn produces the canonical action:tag@url shape.""" + + def test_label_shape(self) -> None: + """Label is "{action}:{tag}@{normalized_url}".""" + action = _build_action( + target_desc='button "Sign in"', + url="/users/12345/profile?ref=x", + ) + assert default_label_fn(action) == 'click:button "Sign in"@/users/:id/profile' + + def test_no_url(self) -> None: + """Missing URL becomes (no-url) placeholder.""" + action = _build_action(url=None) + assert "@(no-url)" in default_label_fn(action) + + +class TestSelectorLabelFn: + """selector_label_fn prefers stable attributes when present.""" + + def test_uses_data_testid_when_present(self) -> None: + """data-testid attribute wins over target_desc.""" + fn = selector_label_fn("data-testid") + action = _build_action( + target_desc="some long ugly description", + metadata={"data-testid": "signin-button"}, + url="/login", + ) + out = fn(action) + assert "signin-button" in out + assert "ugly description" not in out + + def test_falls_back_to_default(self) -> None: + """Missing attribute → behaves like default_label_fn.""" + fn = selector_label_fn("data-testid") + action = _build_action(target_desc="button", url="/login") + assert fn(action) == default_label_fn(action) + + +# ============================================================================= +# Analyzer +# ============================================================================= + + +class TestRrwebAnalyzer: + """Analyzer produces actions + markdown from the sample fixture.""" + + def test_analyzes_sample_fixture(self) -> None: + """Sample fixture produces ≥1 click + navigate actions and 3 navigations.""" + events = _load_sample() + result = RrwebAnalyzer().analyze(events) + + # The sample stream has 4 Meta events (navigations). + navigations = [a for a in result.actions if a.action == "navigate"] + assert len(navigations) == 4 + + # And at least one click on the Sign in button (id=13). + clicks = [a for a in result.actions if a.action == "click"] + assert any('"Sign in"' in a.target_desc for a in clicks) + + # And two inputs (email, password). + inputs = [a for a in result.actions if a.action == "input"] + assert len(inputs) == 2 + + # markdown_summary is non-empty; format is one "{timestamp_seconds}: {description}" + # line per action. + assert result.markdown_summary + assert "Navigated to https://app.example.com/login" in result.markdown_summary + + def test_empty_input_returns_empty_result(self) -> None: + """Empty event stream → empty result, no crash.""" + result = RrwebAnalyzer().analyze([]) + assert result.actions == [] + assert result.markdown_summary == "" + + +# ============================================================================= +# ReplayBundle: projections + aggregations + filters +# ============================================================================= + + +def _sample_bundle() -> ReplayBundle: + """Build a small bundle from synthetic action streams for aggregation tests.""" + r1 = _make_replay( + "r-1", + [ + _build_action( + timestamp=1, action="navigate", target_desc="/login", url="/login" + ), + _build_action( + timestamp=100, action="click", target_desc="button.signin", url="/login" + ), + _build_action( + timestamp=200, + action="navigate", + target_desc="/dashboard", + url="/dashboard", + ), + ], + ) + r2 = _make_replay( + "r-2", + [ + _build_action( + timestamp=1, action="navigate", target_desc="/login", url="/login" + ), + _build_action( + timestamp=50, action="click", target_desc="button.signin", url="/login" + ), + _build_action( + timestamp=70, action="click", target_desc="button.signin", url="/login" + ), + _build_action( + timestamp=90, action="click", target_desc="button.signin", url="/login" + ), + _build_action( + timestamp=1_000_000, + action="navigate", + target_desc="/dashboard", + url="/dashboard", + ), + ], + ) + r3 = _make_replay( + "r-3", + [ + _build_action( + timestamp=1, action="navigate", target_desc="/login", url="/login" + ), + _build_action( + timestamp=500, + action="console_error", + target_desc="TypeError", + url="/login", + ), + ], + ) + return ReplayBundle( + replays=[r1, r2, r3], computed_at="2026-05-27T00:00:00Z", project_id=12345 + ) + + +class TestReplayBundleProjections: + """The seven DataFrame projections have the documented column shape.""" + + def test_sessions_df(self) -> None: + """sessions_df has one row per replay with derived counts.""" + b = _sample_bundle() + df = b.sessions_df + assert len(df) == 3 + for col in ("replay_id", "n_actions", "n_clicks", "n_pages", "n_errors"): + assert col in df.columns + # r-2 has 3 clicks; r-3 has 1 error. + r2_row = df[df["replay_id"] == "r-2"].iloc[0] + assert int(r2_row["n_clicks"]) == 3 + r3_row = df[df["replay_id"] == "r-3"].iloc[0] + assert int(r3_row["n_errors"]) == 1 + + def test_actions_df_long_format(self) -> None: + """actions_df is long-format keyed by replay_id.""" + b = _sample_bundle() + df = b.actions_df + assert "replay_id" in df.columns + # Total actions = 3 + 5 + 2 = 10 + assert len(df) == 10 + + def test_elements_df(self) -> None: + """elements_df aggregates clicks per (target_desc, url).""" + b = _sample_bundle() + df = b.elements_df + if not df.empty: + assert "n_clicks" in df.columns + row = df[df["target_desc"] == "button.signin"].iloc[0] + # 1 click from r1 + 3 from r2 = 4 + assert int(row["n_clicks"]) == 4 + + def test_elements_df_normalizes_urls(self) -> None: + """The same element on parameterized URLs collapses to one row.""" + r = _make_replay( + "r-n", + [ + _build_action( + timestamp=10, + action="click", + target_desc="row", + url="/users/1/profile", + ), + _build_action( + timestamp=20, + action="click", + target_desc="row", + url="/users/2/profile", + ), + ], + ) + b = ReplayBundle(replays=[r], computed_at="t", project_id=12345) + rows = b.elements_df[b.elements_df["target_desc"] == "row"] + assert len(rows) == 1 + assert rows.iloc[0]["url"] == "/users/:id/profile" + assert int(rows.iloc[0]["n_clicks"]) == 2 + + def test_default_df_is_sessions(self) -> None: + """ReplayBundle.df returns sessions_df.""" + b = _sample_bundle() + assert b.df.equals(b.sessions_df) + + +class TestReplayBundleAggregations: + """Bundle aggregation methods return non-empty for relevant fixtures.""" + + def test_top_clicks(self) -> None: + """top_clicks returns button.signin first (4 clicks across bundle).""" + b = _sample_bundle() + out = b.top_clicks() + assert out.iloc[0]["target_desc"] == "button.signin" + assert int(out.iloc[0]["count"]) == 4 + + def test_top_clicks_excludes_focus(self) -> None: + """A focus+click pair on one target counts as a single click.""" + r = _make_replay( + "r-f", + [ + _build_action( + timestamp=10, + action="click", + target_desc="btn", + url="/x", + metadata={"interaction": "focused"}, + ), + _build_action( + timestamp=20, + action="click", + target_desc="btn", + url="/x", + metadata={"interaction": "clicked"}, + ), + ], + ) + b = ReplayBundle(replays=[r], computed_at="t", project_id=12345) + out = b.top_clicks() + assert int(out[out["target_desc"] == "btn"].iloc[0]["count"]) == 1 + + def test_rage_clicks(self) -> None: + """rage_clicks catches r-2's 3-burst on button.signin (50ms span).""" + b = _sample_bundle() + out = b.rage_clicks(threshold=3, window_ms=100) + assert len(out) == 1 + assert out.iloc[0]["replay_id"] == "r-2" + assert int(out.iloc[0]["count"]) == 3 + + def test_rage_clicks_excludes_focus(self) -> None: + """Focus-only rows don't pad a burst (analyzer maps focus→click too).""" + base = 1716810000000 + # Three genuine clicks within the window = one real 3-burst. + real = _make_replay( + "r-real", + [ + _build_action( + timestamp=base + ts, + action="click", + target_desc="button.go", + url="/x", + metadata={"interaction": "clicked"}, + ) + for ts in (0, 10, 20) + ], + ) + # Two genuine clicks padded with focus rows must NOT reach threshold 3. + padded = _make_replay( + "r-padded", + [ + _build_action( + timestamp=base + ts, + action="click", + target_desc="button.go", + url="/x", + metadata={"interaction": interaction}, + ) + for ts, interaction in ( + (0, "focused"), + (10, "clicked"), + (20, "focused"), + (30, "clicked"), + ) + ], + ) + b = ReplayBundle(replays=[real, padded], computed_at="t", project_id=12345) + out = b.rage_clicks(threshold=3, window_ms=100) + assert set(out["replay_id"]) == {"r-real"} + + def test_long_pauses(self) -> None: + """long_pauses catches r-2's ~1000s gap (90ms → 1_000_000ms).""" + b = _sample_bundle() + out = b.long_pauses(threshold_s=10) + assert any(row["replay_id"] == "r-2" for _, row in out.iterrows()) + + +class TestReplayBundleFilters: + """Filters return new bundles that are proper subsets.""" + + def test_filter_predicate(self) -> None: + """filter returns a new bundle with only matching replays.""" + b = _sample_bundle() + out = b.filter(lambda r: r.replay_id == "r-1") + assert [r.replay_id for r in out.replays] == ["r-1"] + # Original is unchanged (immutability). + assert len(b.replays) == 3 + + def test_where_distinct_id(self) -> None: + """where(distinct_id=...) filters to that user.""" + b = _sample_bundle() + out = b.where(distinct_id=None) + # All synthetic replays have distinct_id=None, so all match. + assert len(out.replays) == 3 + + def test_error_sessions(self) -> None: + """error_sessions returns only replays with console errors (r-3).""" + b = _sample_bundle() + out = b.error_sessions() + assert [r.replay_id for r in out.replays] == ["r-3"] + + def test_head_bound(self) -> None: + """head(n) returns up to n replays.""" + b = _sample_bundle() + assert len(b.head(2).replays) == 2 + assert len(b.head(10).replays) == 3 # bound clamped to total + + def test_sample_determinism(self) -> None: + """Same seed → same sample.""" + b = _sample_bundle() + a = [r.replay_id for r in b.sample(2, seed=42).replays] + c = [r.replay_id for r in b.sample(2, seed=42).replays] + assert a == c + assert len(a) == 2 + + +# ============================================================================= +# Aggregator functions directly +# ============================================================================= + + +class TestAggregatorFunctions: + """Module-level aggregators are accessible without going through Bundle.""" + + def test_rage_clicks_module(self) -> None: + """rage_clicks module-level catches the same burst.""" + b = _sample_bundle() + out = rage_clicks(b, threshold=3, window_ms=100) + assert len(out) == 1 + + def test_long_pauses_module(self) -> None: + """long_pauses module-level returns rows for the pause.""" + b = _sample_bundle() + out = long_pauses(b, threshold_s=10) + assert len(out) >= 1 + + def test_top_clicks_module(self) -> None: + """top_clicks module-level matches Bundle.top_clicks.""" + b = _sample_bundle() + assert top_clicks(b).iloc[0]["target_desc"] == "button.signin" diff --git a/tests/unit/test_rrweb_analyzer.py b/tests/unit/test_rrweb_analyzer.py new file mode 100644 index 00000000..05a35523 --- /dev/null +++ b/tests/unit/test_rrweb_analyzer.py @@ -0,0 +1,798 @@ +"""Targeted coverage for the rrweb analyzer (`_internal/replays/rrweb_analyzer.py`). + +Synthetic event streams hit the paths that +`tests/unit/test_replay_bundle.py::TestRrwebAnalyzer` doesn't exercise: +mutation adds/removes/text/attribute changes, console-error plugin events, +selection events with text extraction, mouse-interaction subtypes +(double / right / focus / touch_start), per-source debouncing, and the +DOM tracker's ancestor-traversal fallback. + +All fixtures are hand-built here; no external fixtures or recordings. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from mixpanel_headless._internal.replays.rrweb_analyzer import ( + DOMTracker, + EventAnalyzer, + MarkdownReporter, + RrwebAnalyzer, + analyze_events, +) + +# ============================================================================= +# Tiny event builders +# ============================================================================= + + +def _meta(ts: int, href: str) -> dict[str, Any]: + """Meta event (type 4) carrying a URL.""" + return { + "type": 4, + "data": {"href": href, "width": 1280, "height": 800}, + "timestamp": ts, + } + + +def _full_snapshot(ts: int, root: dict[str, Any]) -> dict[str, Any]: + """FullSnapshot (type 2) wrapping a DOM root.""" + return { + "type": 2, + "data": {"node": root, "initialOffset": {"left": 0, "top": 0}}, + "timestamp": ts, + } + + +def _mutation(ts: int, **payload: Any) -> dict[str, Any]: + """IncrementalSnapshot Mutation (source 0).""" + return {"type": 3, "data": {"source": 0, **payload}, "timestamp": ts} + + +def _click(ts: int, node_id: int, *, click_type: int = 2) -> dict[str, Any]: + """IncrementalSnapshot MouseInteraction (source 2).""" + return { + "type": 3, + "data": {"source": 2, "type": click_type, "id": node_id, "x": 0, "y": 0}, + "timestamp": ts, + } + + +def _scroll(ts: int, node_id: int = 1) -> dict[str, Any]: + """IncrementalSnapshot Scroll (source 3).""" + return { + "type": 3, + "data": {"source": 3, "id": node_id, "x": 0, "y": 0}, + "timestamp": ts, + } + + +def _input( + ts: int, node_id: int, *, text: str = "", checked: bool | None = None +) -> dict[str, Any]: + """IncrementalSnapshot Input (source 5).""" + data: dict[str, Any] = {"source": 5, "id": node_id, "text": text} + if checked is not None: + data["isChecked"] = checked + return {"type": 3, "data": data, "timestamp": ts} + + +def _selection( + ts: int, start: int, end: int, *, start_offset: int = 0, end_offset: int = 0 +) -> dict[str, Any]: + """IncrementalSnapshot Selection (source 14).""" + return { + "type": 3, + "data": { + "source": 14, + "ranges": [ + { + "start": start, + "end": end, + "startOffset": start_offset, + "endOffset": end_offset, + } + ], + }, + "timestamp": ts, + } + + +def _plugin_console_error(ts: int, *messages: str) -> dict[str, Any]: + """Plugin event (type 6) — rrweb console-plugin error payload.""" + return { + "type": 6, + "data": { + "plugin": "rrweb/console@1", + "payload": {"level": "error", "payload": [f'"{m}"' for m in messages]}, + }, + "timestamp": ts, + } + + +def _element_node( + node_id: int, + tag: str, + *, + attributes: dict[str, str] | None = None, + text: str | None = None, + children: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a type=2 element node, optionally with a single text child.""" + child_nodes: list[dict[str, Any]] = list(children or []) + if text is not None: + child_nodes.append({"id": node_id * 1000, "type": 3, "textContent": text}) + return { + "id": node_id, + "type": 2, + "tagName": tag, + "attributes": attributes or {}, + "childNodes": child_nodes, + } + + +def _document_root(*element_children: dict[str, Any]) -> dict[str, Any]: + """Wrap element nodes in a synthetic document root.""" + return { + "id": 1, + "type": 0, + "childNodes": [ + _element_node( + 2, + "html", + attributes={"lang": "en"}, + children=[ + _element_node( + 3, "body", attributes={}, children=list(element_children) + ) + ], + ), + ], + } + + +# ============================================================================= +# Convenience entry points +# ============================================================================= + + +class TestAnalyzeEventsWrapper: + """`analyze_events()` convenience function validation + happy path.""" + + def test_empty_raises_value_error(self) -> None: + """Empty event list raises ValueError per the documented contract.""" + with pytest.raises(ValueError, match="cannot be empty"): + analyze_events([]) + + def test_non_list_raises_value_error(self) -> None: + """Passing a non-list raises ValueError.""" + with pytest.raises(ValueError, match="must be a list"): + analyze_events("not a list") # type: ignore[arg-type] + + def test_returns_string(self) -> None: + """Successful analyze returns the markdown string.""" + events = [_meta(1000, "/x")] + out = analyze_events(events) + assert "Navigated to /x" in out + + def test_actions_carry_description(self) -> None: + """analyze() stamps the full phrase on UserAction.description. + + This is the field Replay.summary_markdown renders; the regression + it guards against is the action carrying only the bare target_desc. + """ + result = RrwebAnalyzer().analyze([_meta(1000, "/x")]) + assert result.actions + assert result.actions[0].description == "Navigated to /x" + # The structured description and the rendered markdown agree. + assert result.actions[0].description in result.markdown_summary + + +# ============================================================================= +# Console errors (the bug the previous from-scratch impl had) +# ============================================================================= + + +class TestConsoleErrors: + """Plugin events with rrweb/console@* + level=error produce console_error actions.""" + + def test_console_error_emitted(self) -> None: + """Plugin payload with level=error becomes a console_error action.""" + events = [ + _meta(1000, "/x"), + _plugin_console_error(2000, "TypeError: bad"), + ] + result = RrwebAnalyzer().analyze(events) + errors = [a for a in result.actions if a.action == "console_error"] + assert len(errors) == 1 + assert "TypeError: bad" in errors[0].target_desc + # Also recorded in the structured errors list. + assert len(result.errors) == 1 + assert result.errors[0].message == "TypeError: bad" + + def test_non_error_plugin_ignored(self) -> None: + """Plugin events with non-error level (e.g. warn) do NOT emit actions.""" + events = [ + _meta(1000, "/x"), + { + "type": 6, + "data": { + "plugin": "rrweb/console@1", + "payload": {"level": "warn", "payload": ['"deprecation"']}, + }, + "timestamp": 2000, + }, + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action == "console_error" for a in result.actions) + + def test_unrelated_plugin_ignored(self) -> None: + """Plugin events from non-rrweb-console plugins are ignored.""" + events = [ + _meta(1000, "/x"), + { + "type": 6, + "data": {"plugin": "rrweb/canvas@1", "payload": {}}, + "timestamp": 2000, + }, + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action == "console_error" for a in result.actions) + + def test_empty_message_not_emitted(self) -> None: + """A console error with no messages produces no action.""" + events = [ + _meta(1000, "/x"), + { + "type": 6, + "data": { + "plugin": "rrweb/console@1", + "payload": {"level": "error", "payload": []}, + }, + "timestamp": 2000, + }, + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action == "console_error" for a in result.actions) + + +# ============================================================================= +# Debouncing — the other big bug in the previous impl +# ============================================================================= + + +class TestDebouncing: + """Scroll / input / selection emit one action per debounce window.""" + + def test_scroll_debounced(self) -> None: + """Five scrolls within 1s produce one scroll action.""" + events = [ + _meta(1000, "/x"), + _scroll(2000), + _scroll(2100), + _scroll(2200), + _scroll(2300), + _scroll(2400), + ] + result = RrwebAnalyzer().analyze(events) + scrolls = [a for a in result.actions if a.action == "scroll"] + # First scroll passes (last_scroll_time=0, gap > 1000); subsequent + # within 1s are suppressed. + assert len(scrolls) == 1 + + def test_scroll_re_fires_after_gap(self) -> None: + """A scroll more than 1s after the previous one re-fires.""" + events = [ + _meta(1000, "/x"), + _scroll(2000), + _scroll(5000), # 3s later → re-fires + ] + result = RrwebAnalyzer().analyze(events) + scrolls = [a for a in result.actions if a.action == "scroll"] + assert len(scrolls) == 2 + + def test_input_debounced_per_node(self) -> None: + """Two inputs on the same node within 1s collapse; two nodes don't.""" + root = _document_root( + _element_node(10, "input", attributes={"id": "email", "type": "text"}), + _element_node( + 11, "input", attributes={"id": "password", "type": "password"} + ), + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _input(2000, 10, text="a"), + _input(2500, 10, text="ab"), # within 1s of prev on node 10 → suppressed + _input(2100, 11, text="x"), # different node → emitted + ] + result = RrwebAnalyzer().analyze(events) + inputs = [a for a in result.actions if a.action == "input"] + assert len(inputs) == 2 + + def test_input_checkbox(self) -> None: + """Checkbox input (is_checked) emits a 'Set ... to checked' description.""" + root = _document_root( + _element_node(20, "input", attributes={"type": "checkbox", "id": "agree"}) + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _input(2000, 20, checked=True), + ] + result = RrwebAnalyzer().analyze(events) + markdown = result.markdown_summary + assert "to checked" in markdown + + def test_input_no_text_no_check_modified_fallback(self) -> None: + """Input with empty text + no is_checked emits 'Modified ...'.""" + root = _document_root( + _element_node(30, "input", attributes={"type": "text", "id": "foo"}) + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _input(2000, 30), # no text, no isChecked + ] + result = RrwebAnalyzer().analyze(events) + assert "Modified" in result.markdown_summary + + +# ============================================================================= +# Mouse-interaction subtypes +# ============================================================================= + + +class TestMouseInteractions: + """All five interaction types (click / dbl / right / focus / touch_start).""" + + @pytest.mark.parametrize( + "click_type,expected_verb,expected_action", + [ + (2, "Clicked", "click"), + (3, "Right-clicked", "click"), + (4, "Double-clicked", "click"), + (5, "Focused", "click"), + (7, "Tapped", "touch_start"), + ], + ) + def test_each_interaction_type( + self, click_type: int, expected_verb: str, expected_action: str + ) -> None: + """Each rrweb interaction type maps to its documented verb + action literal.""" + root = _document_root( + _element_node(40, "button", attributes={"id": "go"}, text="Go") + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(2000, 40, click_type=click_type), + ] + result = RrwebAnalyzer().analyze(events) + # Description contains the upstream-style verb. + assert ( + any(expected_verb in d for _, d in EventAnalyzer().descriptions or []) + or any( + expected_verb in a.target_desc or expected_verb in (a.target_desc or "") + for a in result.actions + ) + or expected_verb in result.markdown_summary + ) + # Structured action carries the documented literal. + action_matches = [a for a in result.actions if a.action == expected_action] + assert len(action_matches) >= 1 + + def test_unknown_interaction_type_ignored(self) -> None: + """An unrecognized MouseInteraction type produces no action.""" + root = _document_root(_element_node(50, "div", attributes={"id": "x"})) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(2000, 50, click_type=99), # not in the enum + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action in ("click", "touch_start") for a in result.actions) + + def test_click_on_unknown_node_describes_as_element(self) -> None: + """Click with a present but unknown node id emits 'Clicked element'. + + DOMTracker.get_node_description returns 'element' for unknown ids. + Only a missing node id (``None``) triggers the "unknown element" + drop path; a present id — including ``0`` — is looked up. + """ + events = [ + _meta(1000, "/x"), + _click(2000, 999), # node 999 never registered + ] + result = RrwebAnalyzer().analyze(events) + clicks = [a for a in result.actions if a.action == "click"] + assert len(clicks) == 1 + assert clicks[0].target_desc == "element" + + def test_click_with_no_node_id_is_dropped(self) -> None: + """Click event without a node id resolves to the drop path.""" + events = [ + _meta(1000, "/x"), + { + "type": 3, + "data": {"source": 2, "type": 2, "x": 0, "y": 0}, + "timestamp": 2000, + }, + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action == "click" for a in result.actions) + + def test_data_selectors_propagated_to_click_metadata(self) -> None: + """A clicked element's ``data-*`` selectors land in UserAction.metadata. + + Regression for the originally-broken ``selector_label_fn``: before + this fix, click metadata only ever carried ``{"interaction": verb}``, + so the analyzer never surfaced ``data-testid`` and the public helper + always fell through to the URL. The metadata must now expose every + ``data-*`` attribute on the clicked node. + """ + root = _document_root( + _element_node( + 40, + "button", + attributes={ + "id": "go", + "data-testid": "signin-button", + "data-cy": "signin", + }, + text="Sign in", + ) + ) + events = [_full_snapshot(1500, root), _click(2000, 40, click_type=2)] + result = RrwebAnalyzer().analyze(events) + clicks = [a for a in result.actions if a.action == "click"] + assert len(clicks) == 1 + assert clicks[0].metadata.get("data-testid") == "signin-button" + assert clicks[0].metadata.get("data-cy") == "signin" + # Non-data attributes stay out of metadata (they feed target_desc only). + assert "id" not in clicks[0].metadata + + def test_selector_label_fn_uses_propagated_testid(self) -> None: + """End-to-end: selector_label_fn groups by the analyzer-populated id. + + Without the propagation fix this label would be the default + ``click:button "Checkout"@/cart``; with it, the helper reads the + ``data-testid`` off metadata and produces the stable selector label. + """ + from mixpanel_headless.replay_labels import selector_label_fn + + root = _document_root( + _element_node( + 41, "button", attributes={"data-testid": "checkout"}, text="Checkout" + ) + ) + events = [ + _meta(1000, "/cart"), + _full_snapshot(1500, root), + _click(2000, 41, click_type=2), + ] + result = RrwebAnalyzer().analyze(events) + click = next(a for a in result.actions if a.action == "click") + assert selector_label_fn("data-testid")(click) == "click:checkout@/cart" + + +# ============================================================================= +# Selection events with text excerpt +# ============================================================================= + + +class TestSelectionEvents: + """Selection events emit a 'Selected ...' action with text extraction.""" + + def test_selection_extracts_text(self) -> None: + """A selection over a known text node emits 'Selected '{excerpt}''.""" + # The DOMTracker copies text-child content up to its parent if the + # parent is interactive, but bare text nodes go in directly. + # For selection, the analyzer looks at `node.text` of the start node. + root = _document_root( + _element_node(60, "p", text="hello world from acme"), + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + # Text content "hello world from acme" — select chars 6..11 = "world" + _selection(2000, 60, 60, start_offset=6, end_offset=11), + ] + result = RrwebAnalyzer().analyze(events) + selects = [a for a in result.actions if a.action == "select"] + assert len(selects) == 1 + # The selection action's description includes 'Selected'. + assert "Selected" in result.markdown_summary + + def test_selection_without_text_fallback(self) -> None: + """Selection with empty ranges produces no action.""" + events = [ + _meta(1000, "/x"), + {"type": 3, "data": {"source": 14, "ranges": []}, "timestamp": 2000}, + ] + result = RrwebAnalyzer().analyze(events) + assert not any(a.action == "select" for a in result.actions) + + def test_selection_unknown_node_fallback(self) -> None: + """Selection over an unknown node emits the 'Selected text' fallback.""" + events = [ + _meta(1000, "/x"), + _selection(2000, 999, 999, start_offset=0, end_offset=5), + ] + result = RrwebAnalyzer().analyze(events) + selects = [a for a in result.actions if a.action == "select"] + assert len(selects) == 1 + assert "Selected text" in result.markdown_summary + + +# ============================================================================= +# Mutations: adds / removes / text changes / attribute changes +# ============================================================================= + + +class TestMutations: + """The DOM tracker applies adds/removes/text/attribute mutations.""" + + def test_mutation_adds(self) -> None: + """A node added via mutation is clickable afterward.""" + root = _document_root() # empty body + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _mutation( + 1800, + adds=[ + { + "parentId": 3, + "node": _element_node(70, "button", text="Click me"), + } + ], + ), + _click(2000, 70), + ] + result = RrwebAnalyzer().analyze(events) + assert any("Click me" in (a.target_desc or "") for a in result.actions) + + def test_mutation_removes(self) -> None: + """A removed node's description falls back to 'element' on later click.""" + root = _document_root(_element_node(80, "button", text="Bye")) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _mutation(1800, removes=[{"id": 80}]), + _click(2000, 80), + ] + result = RrwebAnalyzer().analyze(events) + clicks = [a for a in result.actions if a.action == "click"] + # The button is gone — get_node_description falls back to 'element'. + assert len(clicks) == 1 + assert clicks[0].target_desc == "element" + + def test_mutation_text_change(self) -> None: + """update_text() changes the interactive parent's text + invalidates cache.""" + root = _document_root(_element_node(90, "button", text="Old")) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(1700, 90), # primes the cache with "Old" + _mutation(1800, texts=[{"id": 90 * 1000, "value": "New"}]), + _click(2900, 90), # outside scroll debounce; new description + ] + result = RrwebAnalyzer().analyze(events) + # At least one click description references the new text. + clicks = [a for a in result.actions if a.action == "click"] + assert any('"New"' in (a.target_desc or "") for a in clicks) + + def test_mutation_attribute_change(self) -> None: + """update_attributes() adds descriptive attributes that show up in clicks.""" + root = _document_root(_element_node(100, "button")) # no descriptive attrs + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _mutation( + 1800, + attributes=[{"id": 100, "attributes": {"aria-label": "Submit form"}}], + ), + _click(2000, 100), + ] + result = RrwebAnalyzer().analyze(events) + assert any("Submit form" in (a.target_desc or "") for a in result.actions) + + def test_mutation_text_change_for_unknown_node(self) -> None: + """Text change for an unknown node id is a no-op (no crash).""" + events = [ + _meta(1000, "/x"), + _mutation(1800, texts=[{"id": 999, "value": "ghost"}]), + ] + # Should not raise. + result = RrwebAnalyzer().analyze(events) + assert result.actions + + +# ============================================================================= +# DOMTracker description fallbacks (aria-label / title / alt / placeholder / id / href) +# ============================================================================= + + +class TestDescriptionFallbacks: + """Each descriptive-attribute priority gets exercised.""" + + @pytest.mark.parametrize( + "attributes,text,fragment", + [ + ({"aria-label": "Save changes"}, None, '"Save changes"'), + ({"title": "tooltip-text"}, None, '"tooltip-text"'), + ({"alt": "logo"}, None, 'alt="logo"'), + ({}, "Sign in", '"Sign in"'), + ({"placeholder": "search…"}, None, 'placeholder="search…"'), + ({"id": "go"}, None, "#go"), + ], + ) + def test_button_description( + self, attributes: dict[str, str], text: str | None, fragment: str + ) -> None: + """Each priority fallback produces the documented description fragment.""" + root = _document_root( + _element_node(200, "button", attributes=attributes, text=text) + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(2000, 200), + ] + result = RrwebAnalyzer().analyze(events) + assert any(fragment in (a.target_desc or "") for a in result.actions) + + def test_anchor_with_http_href_appends_path(self) -> None: + """ with a meaningful path appends 'to /path'.""" + root = _document_root( + _element_node( + 210, + "a", + attributes={"href": "https://example.com/docs/intro"}, + text="Docs", + ) + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(2000, 210), + ] + result = RrwebAnalyzer().analyze(events) + assert any("to /docs/intro" in (a.target_desc or "") for a in result.actions) + + def test_input_with_type(self) -> None: + """Input description includes type=... fragment.""" + root = _document_root( + _element_node(220, "input", attributes={"type": "email", "id": "email"}) + ) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _input(2000, 220, text="alice@example.com"), + ] + result = RrwebAnalyzer().analyze(events) + assert any("type=email" in (a.target_desc or "") for a in result.actions) + + def test_ancestor_traversal_fallback(self) -> None: + """Element with no description uses ancestor context (e.g. 'span in button').""" + # Build a button with a child span that has no descriptive info of its own. + span = _element_node(300, "span") + button = _element_node( + 301, + "button", + attributes={"id": "go"}, + text="Go", + children=[span], + ) + root = _document_root(button) + events = [ + _meta(1000, "/x"), + _full_snapshot(1500, root), + _click(2000, 300), # click on the span + ] + result = RrwebAnalyzer().analyze(events) + clicks = [a for a in result.actions if a.action == "click"] + # span has no own description; ancestor context kicks in. + assert any("in button" in (a.target_desc or "") for a in clicks) + + +# ============================================================================= +# DOMTracker direct API exercises +# ============================================================================= + + +class TestDOMTrackerDirect: + """Direct DOMTracker exercises beyond what analyzer integration covers.""" + + def test_sanitize_value_strips_and_drops_none_string(self) -> None: + """Strings that strip to '' or 'none' return empty; ints pass through.""" + assert DOMTracker._sanitize_value(" ") == "" + assert DOMTracker._sanitize_value("None") == "" + assert DOMTracker._sanitize_value("hi ") == "hi" + assert DOMTracker._sanitize_value(42) == 42 + + def test_describe_unknown_node_returns_element(self) -> None: + """Asking for an unknown node id returns the 'element' sentinel.""" + assert DOMTracker().get_node_description(9999) == "element" + + def test_max_nodes_warning(self) -> None: + """Hitting MAX_NODES sets the reached_max_nodes flag.""" + tracker = DOMTracker() + tracker.MAX_NODES = 2 + tracker.add_node(_element_node(1, "div")) + tracker.add_node(_element_node(2, "div")) + tracker.add_node(_element_node(3, "div")) + assert tracker.reached_max_nodes + + def test_max_nodes_caps_growth_after_trip(self) -> None: + """The cap holds for EVERY new node past the trip, not just the first. + + Regression guard: reached_max_nodes used to be ANDed into the skip + condition, so only the first over-limit node was dropped and every + subsequent one was still added — the map grew past MAX_NODES. The flag + must gate the log only; the skip must fire for all new nodes at the cap. + """ + tracker = DOMTracker() + tracker.MAX_NODES = 2 + for node_id in range(1, 8): # add 7 distinct nodes, cap is 2 + tracker.add_node(_element_node(node_id, "div")) + assert tracker.reached_max_nodes + assert len(tracker.nodes) == 2 # nodes 1 and 2 only; 3-7 all skipped + + def test_max_nodes_still_updates_existing_nodes_at_cap(self) -> None: + """At the cap, re-adding an already-tracked node is allowed (no growth). + + The skip only targets NEW nodes (``node_id not in self.nodes``); an + update to a known node must still fall through so its metadata refreshes. + """ + tracker = DOMTracker() + tracker.MAX_NODES = 2 + tracker.add_node(_element_node(1, "button", attributes={"id": "first"})) + tracker.add_node(_element_node(2, "div")) + tracker.add_node(_element_node(3, "div")) # trips the cap, skipped + # Re-add node 1 with new attributes — known id, so it updates in place. + tracker.add_node(_element_node(1, "button", attributes={"id": "updated"})) + assert len(tracker.nodes) == 2 + assert tracker.nodes[1]["attributes"]["id"] == "updated" + + +# ============================================================================= +# MarkdownReporter +# ============================================================================= + + +class TestMarkdownReporter: + """Reporter renders ts/desc pairs as `{ts_seconds}: {desc}` lines.""" + + def test_empty_returns_no_actions_sentinel(self) -> None: + """Empty description list returns the 'No user actions recorded.' sentinel.""" + assert MarkdownReporter([]).generate() == "No user actions recorded." + + def test_renders_seconds(self) -> None: + """Timestamps in ms are divided by 1000 for the line format.""" + out = MarkdownReporter([(2_500, "Did a thing")]).generate() + assert out == "2: Did a thing" + + def test_multiple_lines_joined(self) -> None: + """Multiple (ts, desc) pairs join with newline.""" + out = MarkdownReporter([(1_000, "a"), (2_000, "b")]).generate() + assert out == "1: a\n2: b" + + def test_collapses_consecutive_duplicates(self) -> None: + """Consecutive identical descriptions coalesce into a (×N) suffix.""" + out = MarkdownReporter( + [(1_000, "Clicked X"), (1_200, "Clicked X"), (1_400, "Clicked X")] + ).generate() + # First timestamp of the run is shown; the run length is the suffix. + assert out == "1: Clicked X (×3)" + + def test_non_adjacent_duplicates_not_collapsed(self) -> None: + """Identical descriptions split by a different line stay separate.""" + out = MarkdownReporter( + [(1_000, "Clicked X"), (2_000, "Scrolled"), (3_000, "Clicked X")] + ).generate() + assert out == "1: Clicked X\n2: Scrolled\n3: Clicked X" diff --git a/tests/unit/test_types_replay.py b/tests/unit/test_types_replay.py new file mode 100644 index 00000000..f636b845 --- /dev/null +++ b/tests/unit/test_types_replay.py @@ -0,0 +1,206 @@ +"""Unit tests for Replay (044-session-replay, data-model §2.5). + +The analyzer has shipped: ``Workspace.fetch_replay()`` runs ``RrwebAnalyzer`` +and populates ``actions``. For an actionless replay (``actions=[]``, e.g. a +unit-test fixture), the analyzer-dependent accessors (``summary_markdown``, +``errors``, ``clicks_on``) return safe defaults rather than raising. +""" + +from __future__ import annotations + +from mixpanel_headless.types import Replay, ReplayEvent, UserAction + + +def _meta(ts: int, href: str) -> dict[str, object]: + """Build a Meta-type rrweb event for fixtures.""" + return { + "type": 4, + "data": {"href": href, "width": 1280, "height": 800}, + "timestamp": ts, + } + + +def _click(ts: int, node_id: int) -> dict[str, object]: + """Build an IncrementalSnapshot Click event for fixtures.""" + return { + "type": 3, + "data": {"source": 2, "type": 2, "id": node_id, "x": 100, "y": 200}, + "timestamp": ts, + } + + +def _full_snapshot(ts: int) -> dict[str, object]: + """Build a FullSnapshot event for fixtures.""" + return { + "type": 2, + "data": { + "node": {"id": 1, "type": 0, "childNodes": []}, + "initialOffset": {"left": 0, "top": 0}, + }, + "timestamp": ts, + } + + +def _build( + *, + rrweb_events: list[dict[str, object]] | None = None, + actions: list[UserAction] | None = None, + mixpanel_events: list[ReplayEvent] | None = None, +) -> Replay: + """Build a Replay with a tiny event stream by default.""" + events = ( + rrweb_events + if rrweb_events is not None + else [ + _meta(1716810000000, "https://app.example.com/login"), + _full_snapshot(1716810000500), + _click(1716810002000, 13), + _meta(1716810005000, "https://app.example.com/dashboard"), + ] + ) + return Replay( + replay_id="r-19221", + distinct_id="user-42", + project_id=3713224, + start_time=1716810000000, + end_time=1716810015000, + retention_days=30, + rrweb_events=events, + actions=actions if actions is not None else [], + mixpanel_events=mixpanel_events if mixpanel_events is not None else [], + ) + + +class TestReplayConvenience: + """duration_seconds, to_rrweb_player_json, page_path.""" + + def test_duration_seconds(self) -> None: + """duration_seconds == (end_time - start_time) / 1000.""" + r = _build() + assert r.duration_seconds == 15.0 + + def test_to_rrweb_player_json_returns_sorted_dicts(self) -> None: + """to_rrweb_player_json returns timestamp-sorted dicts.""" + unsorted = [ + _click(1716810002000, 13), + _meta(1716810000000, "https://app.example.com/login"), + _meta(1716810005000, "https://app.example.com/dashboard"), + ] + r = _build(rrweb_events=unsorted) + out = r.to_rrweb_player_json() + timestamps = [int(e["timestamp"]) for e in out] + assert timestamps == sorted(timestamps) + + def test_page_path(self) -> None: + """page_path returns the URL sequence from navigate actions.""" + actions = [ + UserAction( + timestamp=1716810000000, + action="navigate", + target_node_id=None, + target_desc="Navigated to https://app.example.com/login", + url="https://app.example.com/login", + metadata={}, + ), + UserAction( + timestamp=1716810005000, + action="navigate", + target_node_id=None, + target_desc="Navigated to https://app.example.com/dashboard", + url="https://app.example.com/dashboard", + metadata={}, + ), + ] + r = _build(actions=actions) + path = r.page_path() + assert path == [ + "https://app.example.com/login", + "https://app.example.com/dashboard", + ] + + +class TestReplayEventsDataFrame: + """events_df derives from raw rrweb events (data-model §2.5).""" + + def test_columns_documented(self) -> None: + """events_df has t, type, source, mouse_type, target_node_id, url, raw.""" + r = _build() + df = r.events_df + for col in ( + "t", + "type", + "source", + "mouse_type", + "target_node_id", + "url", + "raw", + ): + assert col in df.columns + + def test_row_per_event(self) -> None: + """Each rrweb event produces one row.""" + r = _build() + assert len(r.events_df) == len(r.rrweb_events) + + +class TestReplayActionsDefaultEmpty: + """With no analyzer output, actions defaults to empty; actions_df keeps its schema.""" + + def test_actions_default_empty(self) -> None: + """actions defaults to an empty list.""" + assert _build().actions == [] + + def test_actions_df_empty_with_schema(self) -> None: + """actions_df is empty but carries the documented columns.""" + df = _build().actions_df + assert len(df) == 0 + for col in ( + "t", + "action", + "target_node_id", + "target_desc", + "description", + "url", + "metadata", + ): + assert col in df.columns + + def test_df_default_is_actions_df(self) -> None: + """Replay.df returns actions_df (default projection per FR-018).""" + r = _build() + assert r.df.equals(r.actions_df) + + +class TestReplayAnalyzerAccessorsEmptyActions: + """With actions=[] the analyzer accessors return safe defaults. + + When the Replay is hand-constructed with actions=[] (e.g. a unit-test + fixture) the analyzer-dependent accessors still return sensible + empty/placeholder values rather than raise. + """ + + def test_summary_markdown_placeholder(self) -> None: + """summary_markdown returns a one-line placeholder for an actionless replay.""" + out = _build().summary_markdown + assert "no actions extracted" in out + + def test_errors_empty(self) -> None: + """errors returns an empty DataFrame for an actionless replay.""" + out = _build().errors + assert len(out) == 0 + + def test_clicks_on_empty(self) -> None: + """clicks_on returns an empty DataFrame for an actionless replay.""" + out = _build().clicks_on(lambda _a: True) + assert len(out) == 0 + + +class TestReplayMixpanelDataFrame: + """mixpanel_df shape — empty unless mixpanel_events is populated.""" + + def test_mixpanel_df_empty_default(self) -> None: + """mixpanel_df is empty when mixpanel_events == [] (default).""" + df = _build().mixpanel_df + assert len(df) == 0 + for col in ("t", "event_name", "properties"): + assert col in df.columns diff --git a/tests/unit/test_types_replay_event.py b/tests/unit/test_types_replay_event.py new file mode 100644 index 00000000..84ae002e --- /dev/null +++ b/tests/unit/test_types_replay_event.py @@ -0,0 +1,94 @@ +"""Unit tests for ReplayEvent (044-session-replay, data-model §2.4).""" + +from __future__ import annotations + +import json + +import pytest + +from mixpanel_headless.types import ReplayEvent + + +def _build(**overrides: object) -> ReplayEvent: + """Construct a ReplayEvent with sensible defaults.""" + defaults: dict[str, object] = { + "replay_id": "r-19221", + "event_name": "Login", + "event_time": 1716810000, + "properties": {"$browser": "Chrome", "plan": "pro"}, + } + defaults.update(overrides) + return ReplayEvent(**defaults) # type: ignore[arg-type] + + +class TestReplayEventConstruction: + """Construction validation per data-model.md §2.4.""" + + def test_happy_path(self) -> None: + """All fields populated.""" + e = _build() + assert e.replay_id == "r-19221" + assert e.event_name == "Login" + assert e.event_time == 1716810000 + assert e.properties == {"$browser": "Chrome", "plan": "pro"} + + def test_properties_none_allowed(self) -> None: + """properties may be None when the caller skips enrichment.""" + e = _build(properties=None) + assert e.properties is None + + def test_empty_replay_id_rejected(self) -> None: + """replay_id must be non-empty.""" + with pytest.raises(ValueError, match="replay_id"): + _build(replay_id="") + + def test_empty_event_name_rejected(self) -> None: + """event_name must be non-empty.""" + with pytest.raises(ValueError, match="event_name"): + _build(event_name="") + + def test_non_positive_event_time_rejected(self) -> None: + """event_time must be a positive unix seconds timestamp.""" + with pytest.raises(ValueError, match="event_time"): + _build(event_time=0) + with pytest.raises(ValueError, match="event_time"): + _build(event_time=-1) + + +class TestReplayEventDataFrame: + """ResultWithDataFrame.df projection (data-model §2.4).""" + + def test_columns_documented(self) -> None: + """df has the documented columns.""" + df = _build().df + for col in ("replay_id", "event_name", "event_time", "properties"): + assert col in df.columns + + def test_single_row(self) -> None: + """df is a single-row projection of one event.""" + df = _build().df + assert len(df) == 1 + + def test_values_round_trip(self) -> None: + """Values in the row match the input.""" + df = _build().df + row = df.iloc[0] + assert row["replay_id"] == "r-19221" + assert row["event_name"] == "Login" + assert row["event_time"] == 1716810000 + + +class TestReplayEventRoundTrip: + """to_dict() preserves every field and is JSON-serializable.""" + + def test_to_dict_round_trip(self) -> None: + """All four fields are present after to_dict().""" + d = _build().to_dict() + assert d["replay_id"] == "r-19221" + assert d["event_name"] == "Login" + assert d["event_time"] == 1716810000 + assert d["properties"]["$browser"] == "Chrome" + + def test_to_dict_json_serializable(self) -> None: + """JSON round-trip works.""" + json.dumps(_build().to_dict()) diff --git a/tests/unit/test_types_replay_summary.py b/tests/unit/test_types_replay_summary.py new file mode 100644 index 00000000..76f42068 --- /dev/null +++ b/tests/unit/test_types_replay_summary.py @@ -0,0 +1,114 @@ +"""Unit tests for ReplaySummary (044-session-replay, data-model §2.1).""" + +from __future__ import annotations + +import json + +import pytest + +from mixpanel_headless.types import ReplaySummary + + +def _build(**overrides: object) -> ReplaySummary: + """Build a ReplaySummary with sensible defaults; override per-test as needed.""" + defaults: dict[str, object] = { + "replay_id": "r-19221", + "distinct_id": "user-42", + "project_id": 3713224, + "start_time": 1716810000000, + "retention_days": 30, + } + defaults.update(overrides) + return ReplaySummary(**defaults) # type: ignore[arg-type] + + +class TestReplaySummaryConstruction: + """Construction-time validation per data-model.md §2.1.""" + + def test_happy_path(self) -> None: + """All fields populated correctly.""" + s = _build() + assert s.replay_id == "r-19221" + assert s.distinct_id == "user-42" + assert s.project_id == 3713224 + assert s.start_time == 1716810000000 + assert s.retention_days == 30 + + def test_distinct_id_none_allowed(self) -> None: + """distinct_id may be None for anonymous sessions.""" + s = _build(distinct_id=None) + assert s.distinct_id is None + + def test_empty_replay_id_rejected(self) -> None: + """replay_id must be non-empty.""" + with pytest.raises(ValueError, match="replay_id"): + _build(replay_id="") + + def test_non_positive_project_id_rejected(self) -> None: + """project_id must be positive.""" + with pytest.raises(ValueError, match="project_id"): + _build(project_id=0) + with pytest.raises(ValueError, match="project_id"): + _build(project_id=-1) + + def test_non_positive_start_time_rejected(self) -> None: + """start_time must be a positive unix ms timestamp.""" + with pytest.raises(ValueError, match="start_time"): + _build(start_time=0) + with pytest.raises(ValueError, match="start_time"): + _build(start_time=-1) + + @pytest.mark.parametrize("bad_retention", [0, 2, 5, 14, 60, 100]) + def test_invalid_retention_rejected(self, bad_retention: int) -> None: + """retention_days must be in {1, 7, 30, 90}.""" + with pytest.raises(ValueError, match="retention_days"): + _build(retention_days=bad_retention) + + @pytest.mark.parametrize("good_retention", [1, 7, 30, 90]) + def test_valid_retention_accepted(self, good_retention: int) -> None: + """All four allowed retention values construct cleanly.""" + s = _build(retention_days=good_retention) + assert s.retention_days == good_retention + + +class TestReplaySummaryRoundTrip: + """to_dict() preserves every field.""" + + def test_to_dict_round_trip(self) -> None: + """All fields make it through to_dict().""" + s = _build() + d = s.to_dict() + assert d["replay_id"] == "r-19221" + assert d["distinct_id"] == "user-42" + assert d["project_id"] == 3713224 + assert d["start_time"] == 1716810000000 + assert d["retention_days"] == 30 + + def test_to_dict_json_serializable(self) -> None: + """to_dict output round-trips through json.dumps.""" + json.dumps(_build().to_dict()) + + +class TestReplaySummaryDataFrame: + """ResultWithDataFrame.df returns a single-row DataFrame.""" + + def test_df_single_row(self) -> None: + """df has one row per summary, with the documented columns.""" + s = _build() + df = s.df + assert len(df) == 1 + for col in ( + "replay_id", + "distinct_id", + "project_id", + "start_time", + "retention_days", + ): + assert col in df.columns + assert df.iloc[0]["replay_id"] == "r-19221" + assert df.iloc[0]["retention_days"] == 30 + + def test_df_cached(self) -> None: + """Second .df access returns the same cached object.""" + s = _build() + assert s.df is s.df diff --git a/tests/unit/test_types_signed_replay.py b/tests/unit/test_types_signed_replay.py new file mode 100644 index 00000000..2cef3ab3 --- /dev/null +++ b/tests/unit/test_types_signed_replay.py @@ -0,0 +1,167 @@ +"""Unit tests for SignedReplay (044-session-replay, data-model §2.2). + +Critical: query_string is a bearer credential. These tests lock the masking +behaviour in __repr__/__str__ so a future refactor cannot accidentally leak +credentials into log lines or default-format output. +""" + +from __future__ import annotations + +import json +import time +from unittest.mock import patch + +import pytest + +from mixpanel_headless.types import SignedReplay + +QS = "URLPrefix=ABCDEF&Expires=1716810300&KeyName=K&Signature=zzzzzzzzzz" +URL = "https://cdn.mxpnl.com/srr-us/abc123-3713224/" + + +def _build(**overrides: object) -> SignedReplay: + """Construct a SignedReplay with sensible defaults; tests override per-case.""" + defaults: dict[str, object] = { + "replay_id": "r-19221", + "url": URL, + "query_string": QS, + "env": "prod", + "signed_at": 1716810000.0, + } + defaults.update(overrides) + return SignedReplay(**defaults) # type: ignore[arg-type] + + +class TestSignedReplayMasking: + """Bearer credential never leaks into __repr__/__str__/default logging.""" + + def test_repr_masks_query_string(self) -> None: + """__repr__ replaces query_string with ''.""" + s = _build() + r = repr(s) + assert f"" in r + assert "Signature=" not in r + assert "URLPrefix=" not in r + assert "Expires=" not in r + + def test_str_equals_repr(self) -> None: + """__str__ delegates to __repr__ to keep f-string and print() safe.""" + s = _build() + assert str(s) == repr(s) + + def test_unique_signature_chunk_does_not_leak(self) -> None: + """The unique signature portion of the credential MUST NOT appear in repr/str. + + Uses a credential with a deliberately distinctive 16-char signature so the + check is strong without picking up coincidental short substrings (e.g. + ``=171`` overlapping with ``signed_at=1716810000.0``). + """ + distinctive = ( + "URLPrefix=ABCDEFGHIJKL&Expires=NOPQRSTUVW&" + "KeyName=KEYY&Signature=ZYXWVUTSRQPONMLK" + ) + s = _build(query_string=distinctive) + body = repr(s) + str(s) + # Slide a 12-char window across the credential — none of these chunks + # have any business showing up in repr/str. + for start in range(0, len(distinctive) - 12): + chunk = distinctive[start : start + 12] + assert chunk not in body, ( + f"Query-string chunk {chunk!r} leaked into repr/str: {body!r}" + ) + # Belt-and-braces: the distinctive Signature value MUST NOT appear. + assert "ZYXWVUTSRQPONMLK" not in body + + def test_repr_includes_other_fields(self) -> None: + """Non-credential fields stay visible in repr for debugging.""" + s = _build() + r = repr(s) + assert "r-19221" in r + assert URL in r + assert "'prod'" in r + + +class TestSignedReplayExpiration: + """5-minute TTL — expires_at and is_expired arithmetic.""" + + def test_expires_at_is_signed_at_plus_300(self) -> None: + """SignedReplay.expires_at == signed_at + 300.""" + s = _build(signed_at=1716810000.0) + assert s.expires_at == 1716810300.0 + + def test_is_expired_false_just_before_boundary(self) -> None: + """A URL one second short of the 300s TTL is not yet expired. + + Time is frozen so the 1-second margin can't be crossed by a slow + runner between constructing the object and reading ``is_expired``. + """ + now = 1_716_810_000.0 + with patch("mixpanel_headless.types.time.time", return_value=now): + s = _build(signed_at=now - 299) + assert not s.is_expired + + def test_is_expired_true_at_boundary(self) -> None: + """At exactly +300s the URL is considered expired.""" + s = _build(signed_at=time.time() - 300) + assert s.is_expired + + def test_is_expired_true_well_after(self) -> None: + """Long-stale URLs are obviously expired.""" + s = _build(signed_at=time.time() - 10_000) + assert s.is_expired + + +class TestSignedReplayToDict: + """to_dict() is the documented escape hatch — it includes the credential.""" + + def test_includes_full_credential(self) -> None: + """to_dict preserves query_string verbatim (the documented escape hatch).""" + d = _build().to_dict() + assert d["query_string"] == QS + + def test_includes_warning_key(self) -> None: + """to_dict carries the _warning key flagging the bearer-credential nature.""" + d = _build().to_dict() + assert "_warning" in d + assert "bearer credential" in d["_warning"] + assert "5 minutes" in d["_warning"] + + def test_includes_every_field(self) -> None: + """All five visible fields make it through to_dict.""" + d = _build().to_dict() + for key in ("replay_id", "url", "query_string", "env", "signed_at"): + assert key in d + + def test_to_dict_json_serializable(self) -> None: + """to_dict output round-trips through json.dumps.""" + json.dumps(_build().to_dict()) + + +class TestSignedReplayValidation: + """Construction validation per data-model §2.2.""" + + def test_url_must_end_with_slash(self) -> None: + """url without a trailing slash is rejected.""" + with pytest.raises(ValueError, match="url"): + _build(url="https://cdn.mxpnl.com/srr-us/abc123-3713224") + + def test_empty_query_string_rejected(self) -> None: + """query_string must be non-empty (server contract).""" + with pytest.raises(ValueError, match="query_string"): + _build(query_string="") + + @pytest.mark.parametrize("bad_env", ["staging", "PROD", "test", ""]) + def test_invalid_env_rejected(self, bad_env: str) -> None: + """env must be exactly 'prod' or 'dev'.""" + with pytest.raises(ValueError, match="env"): + _build(env=bad_env) + + @pytest.mark.parametrize("good_env", ["prod", "dev"]) + def test_valid_env_accepted(self, good_env: str) -> None: + """Both documented env values construct cleanly.""" + assert _build(env=good_env).env == good_env + + def test_negative_signed_at_rejected(self) -> None: + """signed_at must be non-negative.""" + with pytest.raises(ValueError, match="signed_at"): + _build(signed_at=-1.0) diff --git a/tests/unit/test_workspace_replays.py b/tests/unit/test_workspace_replays.py new file mode 100644 index 00000000..d47e4bf0 --- /dev/null +++ b/tests/unit/test_workspace_replays.py @@ -0,0 +1,654 @@ +"""Unit tests for Workspace replay methods (044-session-replay). + +These tests verify the Workspace-level wiring and validation: +- list_replays argument validation (XOR, date window required) +- events_for_replay's <=5-properties cap +- fetch_replay's include_mixpanel_events flow +- replays_for_user composes list_replays + fetch_replays into a ReplayBundle + +The ReplaysService is replaced with a MagicMock on each constructed +Workspace so we can assert on the calls without doing real I/O. A separate +test that exercises ReplaysService.discover with a mocked query_fn is in +tests/unit/_internal/test_replays_service.py. +""" + +from __future__ import annotations + +import warnings +from typing import Any +from unittest.mock import MagicMock + +import pytest + +import mixpanel_headless as mp +from mixpanel_headless.types import ( + Replay, + ReplayEvent, + ReplaySummary, + SignedReplay, +) +from tests.conftest import make_session + +# ============================================================================= +# Fixtures +# ============================================================================= + + +def _make_workspace(api_client_mock: MagicMock | None = None) -> mp.Workspace: + """Build a Workspace bound to a fake session for unit tests.""" + api = api_client_mock or MagicMock() + api.project_id = "12345" + return mp.Workspace(session=make_session(), _api_client=api) + + +def _install_mock_replays_service(ws: mp.Workspace) -> MagicMock: + """Replace the workspace's lazy ReplaysService with a MagicMock.""" + svc = MagicMock() + ws._replays_svc = svc + return svc + + +def _summary( + replay_id: str = "r-1", + *, + retention_days: int = 30, + distinct_id: str | None = "u-42", +) -> ReplaySummary: + """Build a ReplaySummary for fixture seeding.""" + return ReplaySummary( + replay_id=replay_id, + distinct_id=distinct_id, + project_id=12345, + start_time=1716810000000, + retention_days=retention_days, + ) + + +def _signed(replay_id: str = "r-1") -> SignedReplay: + """Build a SignedReplay for fixture seeding.""" + return SignedReplay( + replay_id=replay_id, + url="https://cdn.test/srr-us/sha/", + query_string="URLPrefix=A&Signature=S", + env="prod", + signed_at=1716810000.0, + ) + + +def _replay(replay_id: str) -> Replay: + """Build a minimal valid Replay for bundle-assembly tests.""" + return Replay( + replay_id=replay_id, + distinct_id=None, + project_id=12345, + start_time=1716810000000, + end_time=1716810005000, + retention_days=30, + ) + + +# ============================================================================= +# list_replays validation +# ============================================================================= + + +class TestListReplaysValidation: + """Argument validation matches error-messages.md §5.""" + + def test_neither_arg_raises(self) -> None: + """list_replays with no args raises ValueError.""" + ws = _make_workspace() + _install_mock_replays_service(ws) + with pytest.raises(ValueError, match="exactly one"): + ws.list_replays() + + def test_both_args_raise(self) -> None: + """Both distinct_id and replay_ids raises ValueError.""" + ws = _make_workspace() + _install_mock_replays_service(ws) + with pytest.raises(ValueError, match="both were given"): + ws.list_replays(distinct_id="u-1", replay_ids=["r-1"]) + + def test_distinct_id_without_window_raises(self) -> None: + """distinct_id without from_date/to_date raises ValueError.""" + ws = _make_workspace() + _install_mock_replays_service(ws) + with pytest.raises(ValueError, match="from_date"): + ws.list_replays(distinct_id="u-1") + with pytest.raises(ValueError, match="from_date"): + ws.list_replays(distinct_id="u-1", from_date="2026-05-20") + + def test_replay_ids_without_window_works(self) -> None: + """replay_ids alone is enough — date window is inferred.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [] + result = ws.list_replays(replay_ids=["r-1"]) + assert result == [] + svc.discover.assert_called_once() + + def test_empty_result_returns_empty_list(self) -> None: + """Empty discovery → empty list (not raise).""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [] + out = ws.list_replays( + distinct_id="u-1", from_date="2026-05-20", to_date="2026-05-27" + ) + assert out == [] + + +# ============================================================================= +# list_replays issues the documented query call +# ============================================================================= + + +class TestListReplaysQueryCall: + """ReplaysService.discover sees the right kwargs from list_replays.""" + + def test_distinct_id_path_delegates(self) -> None: + """list_replays passes distinct_id + dates straight through to discover.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [_summary()] + + result = ws.list_replays( + distinct_id="u-42", + from_date="2026-05-20", + to_date="2026-05-27", + ) + + assert result == [_summary()] + svc.discover.assert_called_once_with( + distinct_id="u-42", + replay_ids=None, + from_date="2026-05-20", + to_date="2026-05-27", + limit=100, + ) + + def test_discover_uses_workspace_query(self) -> None: + """Real ReplaysService.discover calls the bound Workspace.query. + + Exercises the actual delegation path: build a Workspace, swap its + query method for a Mock, then call list_replays. The query Mock + should see ``$mp_session_record`` plus group_by keys. + """ + from mixpanel_headless._internal.services.replays import ReplaysService + + query_mock = MagicMock() + # query() returns an object whose .series is the parser's input; an + # empty series keeps the parser path simple (returns []). + query_mock.return_value = MagicMock(series={}) + + ws = _make_workspace() + # Replace the lazy service with one that uses the mocked query_fn. + ws._replays_svc = ReplaysService(ws._require_api_client(), query_fn=query_mock) + + ws.list_replays( + distinct_id="u-42", + from_date="2026-05-20", + to_date="2026-05-27", + ) + + assert query_mock.call_count == 1 + # The single positional arg is the event name. + args, kwargs = query_mock.call_args + assert args[0] == "$mp_session_record" + # group_by must include both replay-id and retention. + gb = kwargs.get("group_by", []) + assert "$mp_replay_id" in gb + assert "$mp_replay_retention_period" in gb + # start_time comes from a min(time) aggregation, not a $time grouping. + assert kwargs.get("math") == "min" + assert kwargs.get("math_property") == "$time" + assert kwargs.get("from_date") == "2026-05-20" + assert kwargs.get("to_date") == "2026-05-27" + + def test_replay_ids_path_uses_90_day_lookback(self) -> None: + """Dateless replay_ids hydration reaches Workspace.query with last=90. + + Locks the full _resolve_retention path: a replay older than 30 days + with a 90-day retention window must still be discoverable, so the query + widens from the last=30 default to a 90-day lookback. Otherwise the CDN + walk requests the wrong retention file and a real replay 404s. + """ + from mixpanel_headless._internal.services.replays import ReplaysService + + query_mock = MagicMock(return_value=MagicMock(series={})) + ws = _make_workspace() + ws._replays_svc = ReplaysService(ws._require_api_client(), query_fn=query_mock) + + ws.list_replays(replay_ids=["r-1"]) + + _args, kwargs = query_mock.call_args + assert kwargs.get("last") == 90 + assert "from_date" not in kwargs + assert "to_date" not in kwargs + + +# ============================================================================= +# Retention default + UserWarning +# ============================================================================= + + +class TestRetentionWarning: + """Missing $mp_replay_retention_period defaults to 30 with UserWarning.""" + + def test_missing_retention_emits_userwarning(self) -> None: + """A discover result missing retention triggers UserWarning + default 30.""" + from mixpanel_headless._internal.services.replays import ReplaysService + + query_mock = MagicMock() + # Real series shape for a replay whose only child is the $overall + # rollup — i.e. $mp_replay_retention_period was never stamped. The + # parser must default to 30 and warn, recovering start_time from the + # $overall branch's min-time leaf (unix seconds). + query_mock.return_value = MagicMock( + series={ + "Session Recording Checkpoint [Minimum Time]": { + "$overall": {"all": 1716810000}, + "r-1": {"$overall": {"all": 1716810000}}, + } + } + ) + + ws = _make_workspace() + ws._replays_svc = ReplaysService(ws._require_api_client(), query_fn=query_mock) + + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + result = ws.list_replays( + distinct_id="u-42", + from_date="2026-05-20", + to_date="2026-05-27", + ) + + assert len(result) == 1 + assert result[0].retention_days == 30 + # At least one UserWarning fired naming the missing property. + assert any( + issubclass(w.category, UserWarning) + and "$mp_replay_retention_period" in str(w.message) + for w in recorded + ) + + +# ============================================================================= +# events_for_replay validation +# ============================================================================= + + +class TestEventsForReplayValidation: + """5-property cap on event_properties matches error-messages.md §4.""" + + def test_six_properties_raises_valueerror(self) -> None: + """events_for_replay with 6 props raises ValueError naming the cap.""" + ws = _make_workspace() + _install_mock_replays_service(ws) + with pytest.raises(ValueError, match="at most 5"): + ws.events_for_replay("r-1", event_properties=["a", "b", "c", "d", "e", "f"]) + + def test_six_properties_raises_for_batched_variant(self) -> None: + """events_for_replays enforces the same cap.""" + ws = _make_workspace() + _install_mock_replays_service(ws) + with pytest.raises(ValueError, match="at most 5"): + ws.events_for_replays( + ["r-1"], event_properties=["a", "b", "c", "d", "e", "f"] + ) + + def test_five_properties_ok(self) -> None: + """Exactly 5 properties hits the cap inclusively (no raise).""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.events_for.return_value = {} + ws.events_for_replay("r-1", event_properties=["a", "b", "c", "d", "e"]) + svc.events_for.assert_called_once() + + +# ============================================================================= +# fetch_replay flow +# ============================================================================= + + +class TestFetchReplay: + """fetch_replay signs, fetches, and optionally joins Mixpanel events.""" + + def test_explicit_retention_skips_discovery(self) -> None: + """retention_days set → no list_replays call, single sign + fetch.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed()] + svc.fetch_files.return_value = [ + {"type": 4, "data": {}, "timestamp": 1716810000000}, + {"type": 3, "data": {}, "timestamp": 1716810015000}, + ] + + replay = ws.fetch_replay("r-1", retention_days=30) + + # discover should NOT have been called. + svc.discover.assert_not_called() + # sign + fetch_files should have fired once each. + svc.sign.assert_called_once() + svc.fetch_files.assert_called_once() + # Invariants on the result. + assert isinstance(replay, Replay) + assert replay.replay_id == "r-1" + assert replay.actions == [] + assert replay.duration_seconds == 15.0 + assert replay.mixpanel_events == [] + + def test_include_mixpanel_events_triggers_follow_up(self) -> None: + """include_mixpanel_events=True populates Replay.mixpanel_events.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed()] + svc.fetch_files.return_value = [ + {"type": 4, "data": {}, "timestamp": 1716810000000}, + {"type": 3, "data": {}, "timestamp": 1716810005000}, + ] + svc.events_for.return_value = { + "r-1": [ + ReplayEvent( + replay_id="r-1", + event_name="Login", + event_time=1716810002, + properties={"$browser": "Chrome"}, + ) + ] + } + + replay = ws.fetch_replay("r-1", retention_days=30, include_mixpanel_events=True) + + # events_for fired exactly once, scoped to the replay's own day(s) + # (rrweb timestamps 1716810000000–1716810005000 ms == 2024-05-27 UTC) + # rather than the windowless default. + svc.events_for.assert_called_once() + _args, kwargs = svc.events_for.call_args + assert kwargs["from_date"] == "2024-05-27" + assert kwargs["to_date"] == "2024-05-27" + assert len(replay.mixpanel_events) == 1 + assert replay.mixpanel_events[0].event_name == "Login" + + def test_default_skips_mixpanel_events(self) -> None: + """Without include_mixpanel_events, events_for is not called.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed()] + svc.fetch_files.return_value = [ + {"type": 4, "data": {}, "timestamp": 1716810000000}, + ] + ws.fetch_replay("r-1", retention_days=30) + svc.events_for.assert_not_called() + + def test_retention_none_discovers(self) -> None: + """retention_days=None triggers one list_replays(replay_ids=[id]) call.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [_summary(retention_days=7)] + svc.sign.return_value = [_signed()] + svc.fetch_files.return_value = [ + {"type": 4, "data": {}, "timestamp": 1716810000000}, + ] + + replay = ws.fetch_replay("r-1") # no retention_days + + # discover called with replay_ids=["r-1"] (single-replay hydrate). + svc.discover.assert_called_once_with( + distinct_id=None, + replay_ids=["r-1"], + from_date=None, + to_date=None, + limit=100, + ) + # fetch_files called with the discovered retention. + _args, kwargs = svc.fetch_files.call_args + assert kwargs["retention_days"] == 7 + assert replay.retention_days == 7 + + def test_distinct_id_is_threaded(self) -> None: + """fetch_replay stamps the caller-supplied distinct_id on the Replay.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed()] + svc.fetch_files.return_value = [ + {"type": 4, "data": {}, "timestamp": 1716810000000}, + {"type": 3, "data": {}, "timestamp": 1716810015000}, + ] + + replay = ws.fetch_replay("r-1", distinct_id="u-42", retention_days=30) + + assert replay.distinct_id == "u-42" + + +# ============================================================================= +# replays_for_user stub +# ============================================================================= + + +class TestReplaysForUser: + """replays_for_user returns a ReplayBundle. + + The full coverage of bundle internals is in + tests/unit/test_replay_bundle.py; here we only verify the composition + (list_replays + fetch_replays) and the empty-result short-circuit. + """ + + def test_method_exists(self) -> None: + """The Workspace class advertises replays_for_user.""" + ws = _make_workspace() + assert hasattr(ws, "replays_for_user") + + def test_empty_window_returns_empty_bundle(self) -> None: + """No replays in the window → empty bundle, no fetch_replays call.""" + from mixpanel_headless.types import ReplayBundle + + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [] + bundle = ws.replays_for_user( + "u-42", from_date="2026-05-20", to_date="2026-05-27" + ) + assert isinstance(bundle, ReplayBundle) + assert bundle.replays == [] + svc.sign.assert_not_called() + + +# ============================================================================= +# sign_replay / sign_replays +# ============================================================================= + + +class TestSignReplaysWiring: + """sign_replay/sign_replays delegate to ReplaysService.sign.""" + + def test_sign_replay_returns_first_signed(self) -> None: + """sign_replay is sugar over sign_replays([id])[0].""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed("r-1")] + out = ws.sign_replay("r-1") + assert isinstance(out, SignedReplay) + assert out.replay_id == "r-1" + svc.sign.assert_called_once_with(["r-1"], env="prod") + + def test_sign_replays_passes_through(self) -> None: + """sign_replays just delegates.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.sign.return_value = [_signed("r-1"), _signed("r-2")] + out = ws.sign_replays(["r-1", "r-2"], env="dev") + assert [s.replay_id for s in out] == ["r-1", "r-2"] + svc.sign.assert_called_once_with(["r-1", "r-2"], env="dev") + + +# ============================================================================= +# events_for_replays window passthrough (QA finding #2) +# ============================================================================= + + +class TestEventsForReplaysWindow: + """events_for_replay(s) forward an optional from/to window to the service.""" + + def test_explicit_window_passes_through(self) -> None: + """from_date/to_date reach ReplaysService.events_for unchanged.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.events_for.return_value = {} + ws.events_for_replays(["r-1"], from_date="2026-05-20", to_date="2026-05-21") + _args, kwargs = svc.events_for.call_args + assert kwargs["from_date"] == "2026-05-20" + assert kwargs["to_date"] == "2026-05-21" + + def test_default_window_is_none(self) -> None: + """No explicit window → None forwarded (service applies the 90d lookback).""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.events_for.return_value = {} + ws.events_for_replay("r-1") + _args, kwargs = svc.events_for.call_args + assert kwargs["from_date"] is None + assert kwargs["to_date"] is None + + +# ============================================================================= +# fetch_replays resilience (QA finding #3 — mirror the MCP server) +# ============================================================================= + + +class TestFetchReplaysResilience: + """fetch_replays skips per-replay failures instead of sinking the bundle.""" + + def test_one_failure_does_not_sink_the_bundle(self) -> None: + """A single failing replay is skipped; the rest are returned in order.""" + from mixpanel_headless.exceptions import ReplayNotFoundError + + ws = _make_workspace() + + def fake(replay_id: str, **_kw: Any) -> Replay: + """Fail for r-bad, succeed otherwise.""" + if replay_id == "r-bad": + raise ReplayNotFoundError( + "gone", details={"replay_id": replay_id}, status_code=404 + ) + return _replay(replay_id) + + ws.fetch_replay = MagicMock(side_effect=fake) # type: ignore[method-assign] + bundle = ws.fetch_replays(["r-1", "r-bad", "r-2"]) + assert {r.replay_id for r in bundle.replays} == {"r-1", "r-2"} + + def test_all_failures_raise_first_underlying_error(self) -> None: + """When every replay fails, the first error propagates with its type.""" + from mixpanel_headless.exceptions import ReplayNotFoundError + + ws = _make_workspace() + ws.fetch_replay = MagicMock( # type: ignore[method-assign] + side_effect=ReplayNotFoundError("gone", details={}, status_code=404) + ) + with pytest.raises(ReplayNotFoundError): + ws.fetch_replays(["r-1", "r-2"]) + + +# ============================================================================= +# replays_for_user default limit (QA finding #3) +# ============================================================================= + + +class TestReplaysForUserLimit: + """replays_for_user defaults to a conservative fetch bound.""" + + def test_default_limit_is_20(self) -> None: + """The default limit (20) reaches discover, bounding the byte fetch.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [] # short-circuit before any fetch + ws.replays_for_user("u-42", from_date="2026-05-20", to_date="2026-05-27") + _args, kwargs = svc.discover.call_args + assert kwargs["limit"] == 20 + + +# ============================================================================= +# fetch_replays Insights batching (QA finding #5 — full MCP parity) +# ============================================================================= + + +class TestFetchReplaysBatching: + """fetch_replays threads retention and batches the events join.""" + + def test_retention_by_id_passed_to_each_fetch(self) -> None: + """5a: a retention map lets each fetch skip its discovery query.""" + ws = _make_workspace() + ws.fetch_replay = MagicMock( # type: ignore[method-assign] + side_effect=lambda rid, **_kw: _replay(rid) + ) + ws.fetch_replays(["r-1", "r-2"], retention_by_id={"r-1": 7, "r-2": 90}) + passed = { + call.args[0]: call.kwargs["retention_days"] + for call in ws.fetch_replay.call_args_list + } + assert passed == {"r-1": 7, "r-2": 90} + + def test_events_joined_in_one_batched_call(self) -> None: + """5b: events come from one events_for_replays call, not per replay.""" + ws = _make_workspace() + ws.fetch_replay = MagicMock( # type: ignore[method-assign] + side_effect=lambda rid, **_kw: _replay(rid) + ) + ws.events_for_replays = MagicMock( # type: ignore[method-assign] + return_value={ + "r-1": [ + ReplayEvent( + replay_id="r-1", event_name="Login", event_time=1716810002 + ) + ] + } + ) + bundle = ws.fetch_replays(["r-1", "r-2"], include_mixpanel_events=True) + + # Exactly one batched events query, covering both replays. + ws.events_for_replays.assert_called_once() + args, _kwargs = ws.events_for_replays.call_args + assert set(args[0]) == {"r-1", "r-2"} + # Per-replay fetch never fired its own events query (no fan-out). + for call in ws.fetch_replay.call_args_list: + assert call.kwargs["include_mixpanel_events"] is False + # Events land on the right replay; the other stays empty. + by_id = {r.replay_id: r for r in bundle.replays} + assert [e.event_name for e in by_id["r-1"].mixpanel_events] == ["Login"] + assert by_id["r-2"].mixpanel_events == [] + + def test_no_events_call_when_flag_off(self) -> None: + """Default (no events) makes no events_for_replays call at all.""" + ws = _make_workspace() + ws.fetch_replay = MagicMock( # type: ignore[method-assign] + side_effect=lambda rid, **_kw: _replay(rid) + ) + ws.events_for_replays = MagicMock() # type: ignore[method-assign] + ws.fetch_replays(["r-1"]) + ws.events_for_replays.assert_not_called() + + +class TestReplaysForUserThreadsRetention: + """replays_for_user forwards discovered retention to fetch_replays (5a).""" + + def test_retention_map_built_from_summaries(self) -> None: + """The retention each summary carries is threaded through, not rediscovered.""" + ws = _make_workspace() + svc = _install_mock_replays_service(ws) + svc.discover.return_value = [ + _summary("r-1", retention_days=7), + _summary("r-2", retention_days=90), + ] + ws.fetch_replays = MagicMock( # type: ignore[method-assign] + return_value=MagicMock(replays=[]) + ) + ws.replays_for_user("u-42", from_date="2026-05-20", to_date="2026-05-27") + _args, kwargs = ws.fetch_replays.call_args + assert kwargs["retention_by_id"] == {"r-1": 7, "r-2": 90} + # Every replay is stamped with the user it was discovered for. + assert kwargs["distinct_id_by_id"] == {"r-1": "u-42", "r-2": "u-42"} + + +# Touch Any to keep the import meaningful for type-stubs scenarios. +_ = Any diff --git a/uv.lock b/uv.lock index 55f23182..7283ee56 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-13T11:16:19.253093Z" +exclude-newer = "2026-05-22T06:36:59.531145Z" exclude-newer-span = "P7D" [[package]]