Files
openobserve/tests/mobile-testing/utils/rumChecks.js
Vamsi Karthik R 9a0a272103 test(mobile): P0 session-replay upload assertion (#13945)
## What

Adds a **P0 data-layer assertion** to the mobile RUM masking suite:
after driving the replay flow, it queries the **`_sessionreplay`**
stream and requires that segments actually landed. A rendered dashboard
is no longer accepted as proof that Session Replay works.

This is the check that catches **#13942** — on iOS the RN Session-Replay
bridge posts segments to Datadog's `/api/v2/replay` instead of
OpenObserve's `/replay`, so they're rejected (401, no error surfaced)
and **zero** segments reach `_sessionreplay`. RN iOS session replay
silently records nothing.

## RC

The masking suite verified Session Replay **only through the dashboard
UI** (does the player render + is PII masked). It had no data-layer
check that segments actually reached OpenObserve. So a broken upload
showed only as "the replay didn't render" — ambiguous (a benign
dashboard-render flake *or* a real non-upload) — and that non-render had
been scoped to a skip. The bug slipped through the ambiguity.

## The fix

- **P0 (data):** `maskingSuite` asserts `_sessionreplay` received
segments for the session (`> 0`). Non-upload → hard fail.
Platform-agnostic; catches this whole class of bug.
- **P1 (privacy):** the dashboard render + PII scan stays, run only when
the player paints (segments-landed-but-player-didn't = dashboard flake,
not an SDK bug).
- Removed the `requireReplay` skip hack. **`ios-native` masking
unskipped** → runs as hard P0 (native iOS SDK uses the correct `/replay`
endpoint).
- **`rn-ios` masking is a plain test that hard-FAILS until #13942 is
fixed** — **no skip, no expected-fail marker.** The P0 assertion sees 0
segments → red; it **passes on its own** the moment the SDK starts
uploading. The fix lives in `openobserve-react-native-rum`.

## Expected result per platform

| Platform | Masking (P0 upload + PII) | Why |
|---|:-:|---|
| rn-android | ✅ pass | RN replay works on Android |
| **rn-ios** | ❌ **fail** | **#13942** — 0 replay segments |
| android-native | ✅ pass | native SDK, correct endpoint |
| ios-native | ✅ pass | native SDK, correct endpoint |

So whenever iOS runs (the `mobile-e2e` label / an SDK dispatch), the iOS
job is **red until the SDK fix lands** — an intentional, self-healing
signal.

## Validation (local, real device/data runs)

- `android-native` + `ios-native` masking **pass** the new P0 (segments
present in `_sessionreplay`).
- A non-uploading session returns **0 segments → P0 correctly fails** —
the exact rn-ios symptom.

Scope: only `tests/mobile-testing/` — 3 files.
2026-08-25 04:36:18 +00:00

318 lines
13 KiB
JavaScript

// Reusable per-capability RUM checks, parametrized by platform (service, flows, device).
// This is what keeps the four mobile tracks at CAPABILITY PARITY without copy-pasting the
// same assertion into four spec files: each platform's spec is a thin call into these factories.
//
// Layer split, same as the rest of the suite:
// DRIVE → runFlow(...) taps through the app (utils/maestro.js)
// API → search/pollUntil against _rumdata (utils/ooClient.js)
// UI → RumDashboardPage (pages/rumDashboardPage.js)
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { test, expect } = require('@playwright/test');
const { runFlow } = require('./maestro');
const { apkPath, pull } = require('./adb');
const { search, pollUntil, q } = require('./ooClient');
const { RumDashboardPage } = require('../pages/rumDashboardPage');
const cfg = require('./config');
/** env / service / version tagging is present on the platform's events. */
function attributesSuite({ name, tags, service, env, flows, device = '' }) {
test.describe(name, () => {
test(
'RUM data is tagged with env, service and version',
{ tag: [...tags, '@attributes', '@P1'] },
async () => {
const start = Date.now() - 30000;
for (const f of flows) runFlow(f, { device });
const rows = await pollUntil(
() =>
search(
`SELECT env, service, version FROM ${cfg.RUM_STREAM} WHERE service='${service}'`,
start,
),
(r) => r.length > 0,
{ tries: 20, delayMs: 5000 },
);
// env/version are NOT carried on every RUM event type (e.g. some vitals/long-tasks/resources
// leave them null), so requiring them on EVERY row false-fails once the fuller suite has run.
// Assert instead that no row is MIS-tagged and at least one row carries the expected value —
// a wrong value on any row still fails, but a legitimately-absent one does not.
expect(rows.length, 'data ingested for the service').toBeGreaterThan(0);
expect(rows.every((r) => r.service === service), 'every row tagged with the service').toBe(true);
expect(rows.some((r) => r.env === env), `at least one row tagged env=${env}`).toBe(true);
expect(rows.every((r) => r.env == null || r.env === env), `no row carries a wrong env`).toBe(true);
expect(rows.some((r) => !!r.version), 'at least one row carries an app version').toBe(true);
},
);
});
}
/** Events are attributed to the user set via setUser/setUserInfo (usr_* fields, not "Unknown"). */
function userIdentitySuite({ name, tags, service, userEmail, flows, device = '' }) {
test.describe(name, () => {
test(
'RUM events are attributed to the configured user',
{ tag: [...tags, '@user-identity', '@P1'] },
async () => {
const start = Date.now() - 30000;
for (const f of flows) runFlow(f, { device });
const rows = await pollUntil(
() =>
search(
`SELECT usr_email, usr_id, usr_name FROM ${cfg.RUM_STREAM} WHERE service='${service}'`,
start,
),
(r) => r.some((x) => x.usr_email === userEmail),
{ tries: 20, delayMs: 5000 },
);
const withUser = rows.find((x) => x.usr_email === userEmail);
expect(withUser, `events attributed to ${userEmail}`).toBeTruthy();
expect(withUser.usr_email).toBe(userEmail);
},
);
});
}
/**
* Network / resource tracking — positive AND negative:
* positive: a successful fetch is captured as a resource carrying its URL;
* negative: a failing request (the app's 404) is captured with a >=400 status code.
*/
function networkSuite({ name, tags, service, urlSubstring, flows, device = '' }) {
test.describe(name, () => {
test.describe.configure({ mode: 'serial' });
let resources = [];
test.beforeAll(async () => {
const start = Date.now() - 30000;
for (const f of flows) runFlow(f, { device });
// Poll until BOTH the success URL and a >=400 status have landed (they ingest
// incrementally); falls through to whatever arrived if the error status never does.
resources = await pollUntil(
() =>
search(
`SELECT resource_url, resource_method, resource_status_code, type ` +
`FROM ${cfg.RUM_STREAM} WHERE service='${service}' AND type='resource' ` +
`ORDER BY _timestamp DESC`,
start,
),
(rows) =>
rows.some((r) => (r.resource_url || '').includes(urlSubstring)) &&
rows.some((r) => Number(r.resource_status_code) >= 400),
{ tries: 24, delayMs: 5000 },
);
});
test(
'a successful fetch is captured as a resource',
{ tag: [...tags, '@network', '@P1'] },
async () => {
expect(resources.length, 'at least one resource captured').toBeGreaterThan(0);
expect(
resources.map((r) => r.resource_url || '').some((u) => u.includes(urlSubstring)),
'the app fetch was tracked as a resource',
).toBeTruthy();
},
);
test(
'a failing request captures its 4xx/5xx status (negative)',
{ tag: [...tags, '@network', '@negative', '@P1'] },
async () => {
const statuses = resources
.map((r) => Number(r.resource_status_code))
.filter((n) => !Number.isNaN(n));
expect(
statuses.some((s) => s >= 400),
`a >=400 resource status was captured (saw: ${[...new Set(statuses)].join(', ') || 'none'})`,
).toBeTruthy();
},
);
});
}
/** Session Replay privacy — no on-screen PII may leak into the replay DOM (MASK_ALL). */
// requireReplay (default true): the replay MUST render or the test FAILS — so an Android replay
// regression can't hide. iOS specs pass requireReplay:false because the mobile replay is not always
// rendered for iOS in CI (observed); there it skips-with-reason instead of a false fail. Scoping the
// skip this way keeps enforcement on the platforms where the replay does render.
function maskingSuite({ name, tags, service, pii, flows, device = '' }) {
test.describe(name, () => {
test(
'session replay uploads segments and PII is masked (MASK_ALL)',
{ tag: [...tags, '@replay', '@masking', '@P0'] },
async ({ page }) => {
const start = Date.now() - 30000;
for (const f of flows) runFlow(f, { device });
const sessionId = await q.sessionForService(service, start, { tries: 20, delayMs: 5000 });
expect(sessionId, 'a session was ingested for the masking flow').toBeTruthy();
// P0 — DATA LAYER: Session Replay segments actually reached OpenObserve's `_sessionreplay`
// stream. This is the check that would have caught openobserve#13942 — RN iOS posted replay to
// Datadog's `/api/v2/replay` (rejected, 401, no error surfaced) → ZERO segments. A rendered
// dashboard is NOT sufficient proof; the segments must land in the stream.
const segments = await q.bySql(
`SELECT session_id FROM _sessionreplay WHERE session_id='${sessionId}'`,
start,
{ minHits: 1, tries: 20, delayMs: 5000 },
);
expect(
segments.length,
'session replay segments uploaded to _sessionreplay (see openobserve#13942)',
).toBeGreaterThan(0);
// P1 — PRIVACY: no PII in the rendered replay DOM. The upload above is the hard gate; the
// dashboard player render is secondary (segments-landed-but-player-didn't-paint is a dashboard
// flake, not an SDK bug), so scan for PII only when the player actually renders.
const dash = new RumDashboardPage(page);
await dash.ensureServedOrSkip(test);
await dash.login();
await dash.openSession(sessionId);
if (await dash.replayRendered()) {
await dash.expectNoPiiInReplay(pii);
}
},
);
});
}
/**
* Background/foreground continuity — a view recorded BEFORE backgrounding and one recorded AFTER
* foregrounding must share ONE session_id (the session is not reset by the lifecycle cycle).
* `drive(device)` performs the platform-specific background/foreground (adb on Android, a
* self-contained Maestro flow with the Home key on iOS).
*/
function bgFgSuite({ name, tags, service, viewA, viewB, drive, device = '' }) {
test.describe(name, () => {
test(
'the session continues across a background/foreground cycle',
{ tag: [...tags, '@lifecycle', '@P1'] },
async () => {
const start = Date.now() - 30000;
await drive(device);
// Match by substring — native view names are class-qualified (e.g. "MainActivity").
const hasA = (r) => (r.view_name || '').includes(viewA);
const hasB = (r) => (r.view_name || '').includes(viewB);
// Assert continuity on the NEWEST session only (this test's bg/fg drive, ordered _timestamp
// DESC). Filtering by service+window alone would let a leftover session from an earlier spec
// (e.g. interactions.yaml also records viewA→viewB in one session) either add a 2nd session
// (false failure) or satisfy the check while the real bg/fg split went undetected (false pass).
// The newest session with BOTH views is the drive's own; a split shows as the newest session
// missing viewA → never satisfied → red.
const newestSpansBoth = (r) => {
if (!r.length) return false;
const newest = r[0].session_id;
const nr = r.filter((x) => x.session_id === newest);
return nr.some(hasA) && nr.some(hasB);
};
const rows = await pollUntil(
() =>
search(
`SELECT session_id, view_name, _timestamp FROM ${cfg.RUM_STREAM} ` +
`WHERE service='${service}' AND type='view' ORDER BY _timestamp DESC`,
start,
),
newestSpansBoth,
{ tries: 24, delayMs: 5000 },
);
expect(
newestSpansBoth(rows),
`the newest session spans ${viewA} (pre-bg) and ${viewB} (post-fg)`,
).toBe(true);
},
);
});
}
/** Security (negative): the installed Android APK must contain zero Datadog intake hosts. */
function noPhoneHomeAndroidSuite({ name, tags, appId }) {
test.describe(name, () => {
test(
'the installed app contains zero Datadog hosts',
{ tag: [...tags, '@security', '@no-phone-home', '@negative', '@P0'] },
async () => {
const apk = apkPath(appId);
expect(apk, 'app is installed').toContain('.apk');
const tmp = path.join(os.tmpdir(), `nophonehome-${appId}.apk`);
pull(apk, tmp);
// POSITIVE CONTROL: prove the scan actually read a real APK before trusting a zero result —
// otherwise a failed/empty pull, a broken `unzip`, or missing `strings` all yield "" (the pass
// condition) and this negative test can never fail. `pull()` uses stdio:'ignore', so verify here.
expect(fs.existsSync(tmp) && fs.statSync(tmp).size > 0, 'pulled APK is non-empty').toBe(true);
const markerHits = Number(
execSync(`unzip -p "${tmp}" | strings | grep -icE 'openobserve' || true`).toString().trim(),
);
expect(markerHits, 'APK scan is readable (the openobserve SDK string is present)').toBeGreaterThan(0);
const hits = execSync(
`unzip -p "${tmp}" | strings | ` +
`grep -ioE 'datadoghq\\.(com|eu)|datad0g\\.com|ddog-gov\\.com|browser-intake-datadoghq' | sort -u || true`,
)
.toString()
.trim();
expect(hits, `no Datadog hosts must be present (found: "${hits}")`).toBe('');
},
);
});
}
/** Security (negative): the installed iOS .app bundle must contain zero Datadog intake hosts. */
function noPhoneHomeIosSuite({ name, tags, appId, device }) {
test.describe(name, () => {
test(
'the installed app contains zero Datadog hosts',
{ tag: [...tags, '@security', '@no-phone-home', '@negative', '@P0'] },
async () => {
const appPath = execSync(`xcrun simctl get_app_container ${device || 'booted'} ${appId} app`)
.toString()
.trim();
expect(appPath, 'app is installed').toContain('.app');
// POSITIVE CONTROL: the bundle is readable and carries the OpenObserve SDK, so a zero result
// below means "no Datadog hosts", not "the scan read nothing".
// -print0 | xargs -0: .app bundles can contain paths with spaces, which plain xargs would
// split into bogus args (a false failure).
const marker = Number(
execSync(
`find "${appPath}" -type f -print0 2>/dev/null | xargs -0 strings 2>/dev/null | grep -icE 'openobserve' || true`,
)
.toString()
.trim(),
);
expect(marker, 'app scan is readable (openobserve strings present)').toBeGreaterThan(0);
const hits = execSync(
`find "${appPath}" -type f -print0 2>/dev/null | xargs -0 strings 2>/dev/null | ` +
`grep -ioE 'datadoghq\\.(com|eu)|datad0g\\.com|ddog-gov\\.com|browser-intake-datadoghq' | sort -u || true`,
)
.toString()
.trim();
expect(hits, `no Datadog hosts must be present (found: "${hits}")`).toBe('');
},
);
});
}
module.exports = {
attributesSuite,
userIdentitySuite,
networkSuite,
maskingSuite,
bgFgSuite,
noPhoneHomeAndroidSuite,
noPhoneHomeIosSuite,
};