Files
Vamsi Karthik RandShrinath Rao 6930a9b724 test(mobile): RUM SDK end-to-end suite across 4 platforms + self-contained CI (#13628)
## What this PR adds

end-to-end test suite for the **Mobile RUM SDK** under
`tests/mobile-testing/`, plus a **self-contained CI workflow** that runs
it on real emulators/simulators against an OpenObserve **built from this
PR's source**. Test / fixture / CI only — no product-code/existing test
changes

## How each test works (3 layers)

Every scenario is a real end-to-end journey, so a failure points at the
actual broken layer:

1. **Drive** — a Maestro flow taps through the app like a user
(`maestro/<track>/*.yaml`).
2. **API** — query the `_rumdata` stream and assert the telemetry is
correct (`utils/ooClient.js`, poll-and-retry for async ingestion).
3. **UI** — open the RUM dashboard and assert the sessions

## Coverage (42 tests across 4 platforms)

The **native platforms (android-native, ios-native) are now at parity
with React Native** — each new
native test (masking, network, bg/fg, user-identity, no-phone-home) was
built + validated on a real
emulator/simulator before landing.

| Capability | Layer | RN-Android | Android-native | RN-iOS | iOS-native
|
|---|---|:-:|:-:|:-:|:-:|
| Core telemetry (views/actions/handled-error/crash → `_rumdata`) | API
| ✅ | ✅ | ✅ | ✅ |
| Crash session viewable in dashboard | UI | ✅ | ✅ | ✅ | ✅ |
| Attributes: env / service / version tagging | API | ✅ | ✅ | ✅ | ✅ |
| User identity (`setUser`) | API | ✅ | ✅ | ✅ | ✅ |
| Network: fetch → resource + 4xx/5xx | API | ✅ | ✅ | ✅ | ✅ |
| Session-replay PII masking | UI | ✅ | ✅ | ✅ | ✅ |
| Background / foreground view transitions | API | ✅ | ✅ | — | ✅ |
| No-phone-home (no egress to Datadog hosts) | security | ✅ | ✅ | — | ✅
|

- Shared per-capability factories keep the specs DRY
(`utils/rumChecks.js`, `utils/coreRumSpec.js`).
- Negatives are covered: 4xx capture, PII-must-not-leak (masking), no
phone-home, handled-error ≠ crash.
- `o2-enterprise#2289` dashboard bugs are kept as inline skips that flip
green once fixed.
- Full capability × platform detail + on-device status:
`docs/COVERAGE-MATRIX.md`.

## Fixture apps + build-time config

- Four vendored apps under `apps/`: `o2-rum-tester` (React Native —
drives both RN platforms), `o2-native-android` (Kotlin), `o2-native-ios`
(Swift / xcodegen).
- Each reads its RUM target (endpoint / org / token) and SDK version
from a **build-time-overridable** config. The committed default is the
current pin, so local dev is unchanged; CI regenerates it to point at
the runner's OpenObserve and can pin the latest SDK on release.

## CI — `.github/workflows/mobile-sdk-e2e.yml` (self-contained, no
secrets)

- **Build once per OS from source.** `build_oo_linux` / `build_oo_macos`
compile OpenObserve (binary + embedded web UI) from this PR's code and
cache it. Device jobs download and run it **natively**, and the app
posts real RUM to it (Android reaches the host at `10.0.2.2`, the iOS
simulator via `localhost`). No Docker, no shared cluster, no secrets.
- **Device jobs run in parallel.** `android` (Linux + KVM emulator →
rn-android + android-native) and `ios` (macOS simulator, pinned Xcode
16.4 → rn-ios + ios-native).
- **`merge_reports`** combines both platforms into one browsable HTML
report — even when a device job fails.
- **`decide` picks which platform runs, to keep scarce macOS runners in
check:**
- PR touching Android or shared mobile-RUM code → **Android auto-runs**;
iOS is gated behind the `mobile-e2e` label.
  - `mobile-e2e` label → both platforms (full pre-merge gate).
- `repository_dispatch(mobile-sdk-release)` from an SDK repo → runs that
SDK's platform (companion PRs wire this up:
openobserve-react-native-rum#3, openobserve-sdk-android#1,
openobserve-sdk-ios#1).
  - `workflow_dispatch` → manual, choose platforms.

*Separate workflow :* it needs macOS **and** Android emulators, builds 4
phone apps, and is dispatch-driven — matching the repo's existing
per-suite split

## Status

Green end-to-end on GitHub runners (Android + iOS device jobs + merge) :
https://github.com/openobserve/openobserve/actions/runs/31733717758/job/94565520891

---------

Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
2026-08-20 08:13:35 +00:00

137 lines
4.9 KiB
JavaScript

// OpenObserve Search API client — the "did the right data land?" layer.
// Queries the _rumdata stream with poll-and-retry to absorb ingestion lag.
const cfg = require('./config');
const authHeader = 'Basic ' + Buffer.from(`${cfg.OO_USER}:${cfg.OO_PASS}`).toString('base64');
/** Run one SQL query against _rumdata. times are epoch-ms; the API wants micros. */
// end_time gets a +5min buffer: a CI emulator/simulator clock can run slightly AHEAD of the runner,
// so RUM events land with future-ish timestamps that a `now` upper-bound would exclude (→ 0 rows).
async function search(sql, startMs, endMs = Date.now() + 5 * 60 * 1000, size = 200) {
const res = await fetch(`${cfg.OO_URL}/api/${cfg.OO_ORG}/_search?type=logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({
query: {
sql,
start_time: Math.floor(startMs * 1000),
end_time: Math.floor(endMs * 1000),
size,
},
}),
});
if (!res.ok) {
throw new Error(`_search ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const json = await res.json();
return json.hits || [];
}
// A wrong token/org gives 401/403 that will NEVER self-heal — surface it immediately rather than
// retrying for the whole window and reporting it as "no rows". (404/400/5xx stay retryable: the
// _rumdata stream may simply not exist yet before the first upload.)
const isAuthError = (e) => /_search (401|403)\b/.test((e && e.message) || '');
/**
* Poll a query until it returns at least `minHits` rows (or times out).
* RUM ingestion is asynchronous, so we retry rather than assert instantly.
*/
async function pollSearch(sql, startMs, { minHits = 1, tries = 15, delayMs = 4000 } = {}) {
let last = [];
let lastErr = null;
let gotResponse = false;
for (let i = 0; i < tries; i++) {
try {
last = await search(sql, startMs);
gotResponse = true;
if (last.length >= minHits) return last;
} catch (e) {
lastErr = e;
if (isAuthError(e)) throw e; // non-retryable — fail fast with the real cause
// else transient (network drop / 429 / 5xx / stream-not-ready) — retry.
}
await new Promise((r) => setTimeout(r, delayMs));
}
// Every attempt errored (never a valid response) → surface the cause instead of a silent [].
if (!gotResponse && lastErr) throw lastErr;
return last;
}
/**
* Poll until `predicate(rows)` is satisfied — for assertions that need a COMPLETE
* set of events (which ingest incrementally), not just the first row to arrive.
*/
async function pollUntil(queryFn, predicate, { tries = 20, delayMs = 5000 } = {}) {
let last = [];
let lastErr = null;
let gotResponse = false;
for (let i = 0; i < tries; i++) {
try {
last = await queryFn();
gotResponse = true;
if (predicate(last)) return last;
} catch (e) {
lastErr = e;
if (isAuthError(e)) throw e;
// else transient — retry.
}
await new Promise((r) => setTimeout(r, delayMs));
}
if (!gotResponse && lastErr) throw lastErr;
return last;
}
const esc = (s) => String(s).replace(/'/g, "''");
/** Convenience builders scoped to a service + time window. */
const q = {
bySql: pollSearch,
resources: (service, startMs, opts) =>
pollSearch(
`SELECT session_id, resource_url, resource_method, resource_status_code, type, _timestamp ` +
`FROM ${cfg.RUM_STREAM} WHERE service='${esc(service)}' AND type='resource' ORDER BY _timestamp DESC`,
startMs,
opts,
),
errors: (service, startMs, opts) =>
pollSearch(
`SELECT session_id, error_message, error_source, error_is_crash, view_name, type, _timestamp ` +
`FROM ${cfg.RUM_STREAM} WHERE service='${esc(service)}' AND type='error' ORDER BY _timestamp DESC`,
startMs,
opts,
),
views: (service, startMs, opts) =>
pollSearch(
`SELECT session_id, view_name, type, _timestamp ` +
`FROM ${cfg.RUM_STREAM} WHERE service='${esc(service)}' AND type='view' ORDER BY _timestamp DESC`,
startMs,
opts,
),
// Poll until every name in `requiredNames` has appeared (views ingest incrementally).
viewsUntil: (service, startMs, requiredNames, opts) =>
pollUntil(
() =>
search(
`SELECT view_name FROM ${cfg.RUM_STREAM} ` +
`WHERE service='${esc(service)}' AND type='view'`,
startMs,
),
(rows) => {
const names = new Set(rows.map((r) => r.view_name));
return requiredNames.every((n) => names.has(n));
},
opts,
),
sessionForService: async (service, startMs, opts) => {
const rows = await pollSearch(
`SELECT session_id, _timestamp FROM ${cfg.RUM_STREAM} ` +
`WHERE service='${esc(service)}' ORDER BY _timestamp DESC`,
startMs,
opts,
);
return rows.length ? rows[0].session_id : null;
},
};
module.exports = { search, pollSearch, pollUntil, q, cfg };