diff --git a/.github/workflows/mobile-sdk-e2e.yml b/.github/workflows/mobile-sdk-e2e.yml
index a17a9139f3..628e074873 100644
--- a/.github/workflows/mobile-sdk-e2e.yml
+++ b/.github/workflows/mobile-sdk-e2e.yml
@@ -354,7 +354,8 @@ jobs:
- name: Install Protoc
run: bash ./.github/protoc.sh
- uses: actions/setup-node@v4
- with: { node-version: "20" }
+ # web/package-lock.json needs npm 11, so npm ci fails on Node 20's npm 10.
+ with: { node-version: "24" }
- name: Build binary + web UI (embedded)
env:
NODE_OPTIONS: "--max-old-space-size=8192"
@@ -404,7 +405,8 @@ jobs:
- name: Install Protoc (macOS)
run: brew install protobuf || true
- uses: actions/setup-node@v4
- with: { node-version: "20" }
+ # web/package-lock.json needs npm 11, so npm ci fails on Node 20's npm 10.
+ with: { node-version: "24" }
- name: Build binary + web UI (embedded)
env:
NODE_OPTIONS: "--max-old-space-size=8192"
@@ -449,6 +451,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
+ # Some ubuntu-24.04 images leave too little disk for the emulator system image ("No space left on device").
+ - name: Remove unused tools
+ run: |
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /opt/ghc
+ sudo rm -rf "/usr/local/share/boost"
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+ df -h /
+
- uses: actions/checkout@v4
# Test the LATEST SDK on a release event: the mobile-sdk-release dispatch carries {sdk, version};
diff --git a/tests/mobile-testing/README.md b/tests/mobile-testing/README.md
index 95f7fdf1cf..20b52657a6 100644
--- a/tests/mobile-testing/README.md
+++ b/tests/mobile-testing/README.md
@@ -76,7 +76,7 @@ npx playwright test --grep @masking
npx playwright test --grep @known-bug # the skipped #2289 markers
```
-### Coverage (all four mobile tracks) — 42 tests
+### Coverage (all four mobile tracks) — 46 tests
Each platform has a **core RUM** bundle (`utils/coreRumSpec.js`: views + actions + handled error +
native crash in `_rumdata`, then the crashed session rendering in the dashboard) **plus**
per-capability specs built from shared factories in **`utils/rumChecks.js`** — so the platforms stay
@@ -87,14 +87,15 @@ at parity without copy-paste:
- `networkSuite` — a successful fetch is a resource (**positive**) + a 4xx status is captured (**negative**)
- `maskingSuite` — no PII leaks into the session replay (MASK_ALL)
- `bgFgSuite` — a pre-background view and a post-foreground view share one session
+- `logsSuite` — one tap's info/warn/error log burst lands in `_rumlog` as exactly three rows
- `noPhoneHomeAndroidSuite` — the installed APK contains zero Datadog hosts (**security/negative**)
| Project | App | Service | Specs |
|---|---|---|---|
-| `rn-android` | `apps/o2-rum-tester` (Android) | `o2-rum-tester` | crash, network(+404), handled-error, views, masking, attributes, user-identity, background-foreground, no-phone-home |
-| `android-native` | `apps/o2-native-android` | `o2-native-android` | core bundle, attributes, user-identity, no-phone-home |
-| `rn-ios` | `apps/o2-rum-tester` (iOS) | `o2-rum-tester` | core bundle, network(+404), attributes, user-identity, masking |
-| `ios-native` | `apps/o2-native-ios` | `o2-native-ios` | core bundle, attributes |
+| `rn-android` | `apps/o2-rum-tester` (Android) | `o2-rum-tester` | crash, network(+404), handled-error, views, masking, attributes, user-identity, background-foreground, logs, no-phone-home |
+| `android-native` | `apps/o2-native-android` | `o2-native-android` | core bundle, attributes, user-identity, logs, no-phone-home |
+| `rn-ios` | `apps/o2-rum-tester` (iOS) | `o2-rum-tester` | core bundle, network(+404), attributes, user-identity, masking, logs |
+| `ios-native` | `apps/o2-native-ios` | `o2-native-ios` | core bundle, attributes, logs |
See **`docs/COVERAGE-MATRIX.md`** for the full capability×platform matrix, on-device validation
status, and the deferred follow-ups (native/iOS background-foreground, native network/masking,
@@ -103,7 +104,7 @@ iOS-native user identity — each needs a fixture-app change or a background har
Prereqs: Android emulator booted for the Android projects; an iOS simulator booted (udid in
`.env` `IOS_SIM_UDID`) for the iOS projects; the fixture apps built + installed (see `apps/`).
-Tags: `@mobile @rn-android @rn-ios @android-native @ios-native @P0 @P1 @crash @network @negative @errors @views @replay @masking @lifecycle @attributes @user-identity @security @known-bug`.
+Tags: `@mobile @rn-android @rn-ios @android-native @ios-native @P0 @P1 @crash @network @negative @errors @views @replay @masking @lifecycle @logs @attributes @user-identity @security @known-bug`.
## Notes
diff --git a/tests/mobile-testing/apps/o2-native-android/app/src/main/java/com/o2native/MainActivity.kt b/tests/mobile-testing/apps/o2-native-android/app/src/main/java/com/o2native/MainActivity.kt
index f5326b7c1b..c15e255119 100644
--- a/tests/mobile-testing/apps/o2-native-android/app/src/main/java/com/o2native/MainActivity.kt
+++ b/tests/mobile-testing/apps/o2-native-android/app/src/main/java/com/o2native/MainActivity.kt
@@ -8,6 +8,7 @@ import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
+import com.openobserve.android.log.Logger
import com.openobserve.android.okhttp.OpenObserveInterceptor
import com.openobserve.android.rum.GlobalRumMonitor
import com.openobserve.android.rum.RumActionType
@@ -29,6 +30,8 @@ class MainActivity : AppCompatActivity() {
)
.build()
+ private val logger by lazy { Logger.Builder().setName("o2-e2e").build() }
+
private fun fetch(url: String) {
http.newCall(Request.Builder().url(url).build()).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {}
@@ -82,6 +85,13 @@ class MainActivity : AppCompatActivity() {
button("Fetch resource 404") {
fetch("https://jsonplaceholder.typicode.com/this-path-does-not-exist-404")
}
+ // Several logs per tap so the SDK uploads a multi-event batch (#16229).
+ button("Send logs") {
+ val burst = mapOf("log_burst" to System.currentTimeMillis().toString())
+ logger.i("O2 log burst info", attributes = burst)
+ logger.w("O2 log burst warn", attributes = burst)
+ logger.e("O2 log burst error", attributes = burst)
+ }
button("Trigger native crash") {
throw RuntimeException("O2 Native — intentional uncaught crash")
}
diff --git a/tests/mobile-testing/apps/o2-native-ios/Sources/MainViewController.swift b/tests/mobile-testing/apps/o2-native-ios/Sources/MainViewController.swift
index fe088c8286..fce597653d 100644
--- a/tests/mobile-testing/apps/o2-native-ios/Sources/MainViewController.swift
+++ b/tests/mobile-testing/apps/o2-native-ios/Sources/MainViewController.swift
@@ -1,9 +1,12 @@
import UIKit
import OpenObserveRUM
+import OpenObserveLogs
class MainViewController: UIViewController {
// A URLSession built with the instrumented delegate → its requests are tracked as RUM resources.
private let session = URLSession(configuration: .default, delegate: O2SessionDelegate(), delegateQueue: nil)
+ // Module-qualified because os.Logger shares the name.
+ private lazy var logger = OpenObserveLogs.Logger.create(with: .init(name: "o2-e2e"))
private func fetch(_ url: String) {
guard let u = URL(string: url) else { return }
@@ -41,6 +44,7 @@ class MainViewController: UIViewController {
stack.addArrangedSubview(button("Go to Checkout", #selector(goToCheckout)))
stack.addArrangedSubview(button("Fetch resource 200", #selector(fetch200)))
stack.addArrangedSubview(button("Fetch resource 404", #selector(fetch404)))
+ stack.addArrangedSubview(button("Send logs", #selector(sendLogs)))
stack.addArrangedSubview(button("Trigger native crash", #selector(triggerCrash)))
}
@@ -51,6 +55,14 @@ class MainViewController: UIViewController {
@objc func fetch200() { fetch("https://jsonplaceholder.typicode.com/todos/1") }
@objc func fetch404() { fetch("https://jsonplaceholder.typicode.com/this-path-does-not-exist-404") }
+ // Several logs per tap so the SDK uploads a multi-event batch (#16229).
+ @objc func sendLogs() {
+ let burst: [String: Encodable] = ["log_burst": String(Int(Date().timeIntervalSince1970 * 1000))]
+ logger.info("O2 log burst info", attributes: burst)
+ logger.warn("O2 log burst warn", attributes: burst)
+ logger.error("O2 log burst error", attributes: burst)
+ }
+
@objc func handledError() {
RUMMonitor.shared().addError(
error: NSError(domain: "O2NativeIOS", code: 1,
diff --git a/tests/mobile-testing/apps/o2-rum-tester/App.tsx b/tests/mobile-testing/apps/o2-rum-tester/App.tsx
index bd006477e1..b0422f5fed 100644
--- a/tests/mobile-testing/apps/o2-rum-tester/App.tsx
+++ b/tests/mobile-testing/apps/o2-rum-tester/App.tsx
@@ -20,6 +20,7 @@ import {
OpenObserveProvider,
OpenObserveProviderConfiguration,
TrackingConsent,
+ O2Logs,
O2Rum,
O2SdkReactNative,
RumActionType,
@@ -145,6 +146,15 @@ function HomeScreen({go}: {go: (s: string) => void}) {
}
};
+ // Several logs per tap so the SDK uploads a multi-event batch (#16229).
+ const sendLogs = async () => {
+ const burst = {log_burst: String(Date.now())};
+ await O2Logs.info('O2 log burst info', burst);
+ await O2Logs.warn('O2 log burst warn', burst);
+ await O2Logs.error('O2 log burst error', burst);
+ setStatus('logs sent');
+ };
+
const crash = () => {
O2Rum.addAction(RumActionType.TAP, 'Trigger crash');
// uncaught -> native crash report on next launch
@@ -161,6 +171,7 @@ function HomeScreen({go}: {go: (s: string) => void}) {
go('details')} />
go('form')} />
+
{status}
);
diff --git a/tests/mobile-testing/docs/CI-NOTES.md b/tests/mobile-testing/docs/CI-NOTES.md
index a1053fe9ff..d8531e4651 100644
--- a/tests/mobile-testing/docs/CI-NOTES.md
+++ b/tests/mobile-testing/docs/CI-NOTES.md
@@ -113,7 +113,7 @@ cleartext-HTTP handling are recorded below / in the git history.
## Validated locally
-- Suite parses (`playwright --list`): 42 tests, 4 platforms (+ sdk-drift project).
+- Suite parses (`playwright --list`): 46 tests, 4 platforms (+ sdk-drift project).
- `gen-config.js` / `gen-config.sh` keep the committed **placeholders** when no env is set, and
substitute `O2_RUM_*` when set.
- Android/iOS native config wiring is in place (`BuildConfig.OO_*`, `GeneratedConfig`).
diff --git a/tests/mobile-testing/docs/COVERAGE-MATRIX.md b/tests/mobile-testing/docs/COVERAGE-MATRIX.md
index 97576b3605..6b924e3c0e 100644
--- a/tests/mobile-testing/docs/COVERAGE-MATRIX.md
+++ b/tests/mobile-testing/docs/COVERAGE-MATRIX.md
@@ -1,6 +1,6 @@
# Mobile RUM SDK — Coverage Matrix & Gap Analysis
-_Coverage of the `tests/mobile-testing` suite across the four mobile tracks. **42 tests, 5 Playwright
+_Coverage of the `tests/mobile-testing` suite across the four mobile tracks. **46 tests, 5 Playwright
projects** (rn-android, android-native, rn-ios, ios-native, sdk-drift). The native tracks are now at
RN parity — masking, network, background/foreground, user-identity and no-phone-home ship for both
`android-native` and `ios-native` (previously listed here as deferred). The PR-description matrix is the
@@ -14,7 +14,7 @@ capabilities is superseded by the shipped specs._
(`pages/rumDashboardPage.js`).
- **Per-capability factories** (`utils/rumChecks.js`) keep the four platforms at parity without
copy-paste: `attributesSuite`, `userIdentitySuite`, `networkSuite` (positive + 404 negative),
- `maskingSuite`, `bgFgSuite`, `noPhoneHomeAndroidSuite`. Each platform's spec is a thin call.
+ `maskingSuite`, `bgFgSuite`, `logsSuite`, `noPhoneHomeAndroidSuite`. Each platform's spec is a thin call.
- **`coreRumSpec.js`** bundles the end-to-end crash journey (views + actions + handled error +
native crash in `_rumdata`, then the crashed session rendering in the dashboard) for the three
non-RN-Android platforms.
@@ -37,6 +37,7 @@ follow-ups) · **⚠** blocked by dashboard bug o2-enterprise#2289.
| Attributes: env/service/version (API) | ✓ | ✓ | ✓ | ✓ |
| Session replay privacy masking (UI, negative) | ✓ | N/A no form | ✓ | N/A no form |
| Background/foreground continuity (API) | ✓ | ⏭ | ⏭ | ⏭ |
+| Logs upload: info/warn/error burst lands in `_rumlog` (API) | ✓ | ✓ | ✓ | ✓ |
| No-phone-home: no Datadog hosts (security, negative) | ✓ | ✓ | N/A IPA | N/A IPA |
| Crashed session viewable (UI) | ✓ | ✓ | ✓ | ✓ |
| Error Tracking tab shows error (UI) | ⚠ skip | ⚠ skip | ⚠ skip | ⚠ skip |
diff --git a/tests/mobile-testing/maestro/android-native/logs.yaml b/tests/mobile-testing/maestro/android-native/logs.yaml
new file mode 100644
index 0000000000..f65e541108
--- /dev/null
+++ b/tests/mobile-testing/maestro/android-native/logs.yaml
@@ -0,0 +1,11 @@
+appId: com.o2native
+---
+# One tap writes an info/warn/error burst through the Logs SDK; the spec asserts all three reach _rumlog.
+- launchApp:
+ clearState: false
+- scrollUntilVisible:
+ element:
+ text: "Send logs"
+- tapOn: "Send logs"
+- waitForAnimationToEnd:
+ timeout: 3000
diff --git a/tests/mobile-testing/maestro/ios-native/logs.yaml b/tests/mobile-testing/maestro/ios-native/logs.yaml
new file mode 100644
index 0000000000..3df9506e5f
--- /dev/null
+++ b/tests/mobile-testing/maestro/ios-native/logs.yaml
@@ -0,0 +1,9 @@
+appId: com.o2native.ios
+---
+# One tap writes an info/warn/error burst through the Logs SDK; the spec asserts all three reach _rumlog.
+- launchApp:
+ clearState: false
+- assertVisible: "O2 Native iOS"
+- tapOn: "Send logs"
+- waitForAnimationToEnd:
+ timeout: 3000
diff --git a/tests/mobile-testing/maestro/ios-react-native/logs.yaml b/tests/mobile-testing/maestro/ios-react-native/logs.yaml
new file mode 100644
index 0000000000..b34b179a5d
--- /dev/null
+++ b/tests/mobile-testing/maestro/ios-react-native/logs.yaml
@@ -0,0 +1,15 @@
+# Drives O2RumTester on iOS: one tap writes an info/warn/error burst through O2Logs (the spec asserts _rumlog).
+appId: org.reactjs.native.example.O2RumTester
+---
+- launchApp:
+ clearState: false
+- extendedWaitUntil:
+ visible: "O2 RUM Tester"
+ timeout: 40000
+- scrollUntilVisible:
+ element:
+ text: "Send logs"
+- tapOn: "Send logs"
+- assertVisible: "logs sent"
+- waitForAnimationToEnd:
+ timeout: 3000
diff --git a/tests/mobile-testing/maestro/react-native/logs.yaml b/tests/mobile-testing/maestro/react-native/logs.yaml
new file mode 100644
index 0000000000..22b144cf7e
--- /dev/null
+++ b/tests/mobile-testing/maestro/react-native/logs.yaml
@@ -0,0 +1,15 @@
+# Drives O2RumTester: one tap writes an info/warn/error burst through O2Logs (the spec asserts _rumlog).
+appId: com.o2rumtester
+---
+- launchApp:
+ clearState: false
+- extendedWaitUntil:
+ visible: "O2 RUM Tester"
+ timeout: 40000
+- scrollUntilVisible:
+ element:
+ text: "Send logs"
+- tapOn: "Send logs"
+- assertVisible: "logs sent"
+- waitForAnimationToEnd:
+ timeout: 3000
diff --git a/tests/mobile-testing/specs/android-native.logs.spec.js b/tests/mobile-testing/specs/android-native.logs.spec.js
new file mode 100644
index 0000000000..36895db5b8
--- /dev/null
+++ b/tests/mobile-testing/specs/android-native.logs.spec.js
@@ -0,0 +1,9 @@
+const { logsSuite } = require('../utils/rumChecks');
+const cfg = require('../utils/config');
+
+logsSuite({
+ name: 'Android-native · Logs upload (multi-event batch)',
+ tags: ['@mobile', '@android-native'],
+ service: cfg.NATIVE_ANDROID_SERVICE,
+ flows: ['android-native/logs.yaml'],
+});
diff --git a/tests/mobile-testing/specs/ios-native.logs.spec.js b/tests/mobile-testing/specs/ios-native.logs.spec.js
new file mode 100644
index 0000000000..35140f3019
--- /dev/null
+++ b/tests/mobile-testing/specs/ios-native.logs.spec.js
@@ -0,0 +1,10 @@
+const { logsSuite } = require('../utils/rumChecks');
+const cfg = require('../utils/config');
+
+logsSuite({
+ name: 'iOS-native · Logs upload (multi-event batch)',
+ tags: ['@mobile', '@ios-native'],
+ service: cfg.NATIVE_IOS_SERVICE,
+ flows: ['ios-native/logs.yaml'],
+ device: cfg.IOS_SIM_UDID,
+});
diff --git a/tests/mobile-testing/specs/rn-android.logs.spec.js b/tests/mobile-testing/specs/rn-android.logs.spec.js
new file mode 100644
index 0000000000..618e7cbc5c
--- /dev/null
+++ b/tests/mobile-testing/specs/rn-android.logs.spec.js
@@ -0,0 +1,9 @@
+const { logsSuite } = require('../utils/rumChecks');
+const cfg = require('../utils/config');
+
+logsSuite({
+ name: 'RN Android · Logs upload (multi-event batch)',
+ tags: ['@mobile', '@rn-android'],
+ service: cfg.RN_SERVICE,
+ flows: ['react-native/logs.yaml'],
+});
diff --git a/tests/mobile-testing/specs/rn-ios.logs.spec.js b/tests/mobile-testing/specs/rn-ios.logs.spec.js
new file mode 100644
index 0000000000..c5cc07fc19
--- /dev/null
+++ b/tests/mobile-testing/specs/rn-ios.logs.spec.js
@@ -0,0 +1,10 @@
+const { logsSuite } = require('../utils/rumChecks');
+const cfg = require('../utils/config');
+
+logsSuite({
+ name: 'RN iOS · Logs upload (multi-event batch)',
+ tags: ['@mobile', '@rn-ios'],
+ service: cfg.RN_SERVICE,
+ flows: ['ios-react-native/logs.yaml'],
+ device: cfg.IOS_SIM_UDID,
+});
diff --git a/tests/mobile-testing/utils/config.js b/tests/mobile-testing/utils/config.js
index b662112c7a..c8d0af01c7 100644
--- a/tests/mobile-testing/utils/config.js
+++ b/tests/mobile-testing/utils/config.js
@@ -14,6 +14,7 @@ module.exports = {
OO_PASS: need('OO_PASS'),
RN_ENV: process.env.RN_ENV || 'production',
RUM_STREAM: '_rumdata',
+ RUM_LOG_STREAM: '_rumlog',
// Per-platform app ids + service names (the RN app is one service across both platforms;
// distinguish RN-iOS vs RN-Android by `source`/`os`, natives have their own service).
diff --git a/tests/mobile-testing/utils/ooClient.js b/tests/mobile-testing/utils/ooClient.js
index 4b514a21dd..cf89f11c56 100644
--- a/tests/mobile-testing/utils/ooClient.js
+++ b/tests/mobile-testing/utils/ooClient.js
@@ -122,6 +122,18 @@ const q = {
},
opts,
),
+ // SELECT * because naming the custom log_burst column fails with "unknown field" until a burst has landed.
+ logsUntil: (service, startMs, messagePrefix, predicate, opts) =>
+ pollUntil(
+ () =>
+ search(
+ `SELECT * FROM ${cfg.RUM_LOG_STREAM} ` +
+ `WHERE service='${esc(service)}' AND message LIKE '${esc(messagePrefix)}%' ORDER BY _timestamp DESC`,
+ startMs,
+ ),
+ predicate,
+ opts,
+ ),
sessionForService: async (service, startMs, opts) => {
const rows = await pollSearch(
`SELECT session_id, _timestamp FROM ${cfg.RUM_STREAM} ` +
diff --git a/tests/mobile-testing/utils/rumChecks.js b/tests/mobile-testing/utils/rumChecks.js
index 43d1f79406..ab5208e1f4 100644
--- a/tests/mobile-testing/utils/rumChecks.js
+++ b/tests/mobile-testing/utils/rumChecks.js
@@ -233,6 +233,54 @@ function bgFgSuite({ name, tags, service, viewA, viewB, drive, device = '' }) {
});
}
+/** Logs upload: one tap's info/warn/error burst reaches _rumlog as exactly three rows (#16229). */
+function logsSuite({ name, tags, service, flows, device = '' }) {
+ const prefix = 'O2 log burst';
+ const sent = ['error', 'info', 'warn'];
+ // The newest burst is this run's tap; earlier bursts in the window come from retries.
+ const newestBurst = (rows) =>
+ rows.length ? rows.filter((r) => r.log_burst === rows[0].log_burst) : [];
+
+ test.describe(name, () => {
+ test(
+ 'a multi-event log batch is uploaded and every event lands in _rumlog',
+ { tag: [...tags, '@logs', '@P0'] },
+ async () => {
+ const start = Date.now() - 30000;
+ for (const f of flows) runFlow(f, { device });
+
+ const rows = await q.logsUntil(
+ service,
+ start,
+ prefix,
+ (r) => newestBurst(r).length >= sent.length,
+ { tries: 24, delayMs: 5000 },
+ );
+
+ const burst = newestBurst(rows);
+ expect(
+ burst.length,
+ `rows for the newest log burst (saw ${rows.length} burst rows in total)`,
+ ).toBe(sent.length);
+ expect(burst[0].log_burst, 'the custom log attribute is stored').toBeTruthy();
+ expect(burst.map((r) => r.status).sort(), 'one row per level sent').toEqual(sent);
+ expect(
+ burst.every((r) => r.message === `${prefix} ${r.status}`),
+ 'each message matches its level',
+ ).toBe(true);
+ expect(
+ burst.every((r) => r.service === service),
+ 'every row tagged with the service',
+ ).toBe(true);
+ expect(
+ burst.every((r) => !!r.session_id),
+ 'every log carries the RUM session_id',
+ ).toBe(true);
+ },
+ );
+ });
+}
+
/** Security (negative): the installed Android APK must contain zero Datadog intake hosts. */
function noPhoneHomeAndroidSuite({ name, tags, appId }) {
test.describe(name, () => {
@@ -312,6 +360,7 @@ module.exports = {
networkSuite,
maskingSuite,
bgFgSuite,
+ logsSuite,
noPhoneHomeAndroidSuite,
noPhoneHomeIosSuite,
};