What it is and what your testers get
The SDK is a small library you add to your own app. It turns every test build into a bug-reporting tool, with nothing for your testers to install or sign in to.
Shake to report
Two quick shakes open the report form. You can also add your own "Report a bug" button.
Screenshot with drawing
The current screen, with text fields blacked out. Testers circle the problem with Pen, Undo and Clear.
Screen recording
Up to 5 minutes, no audio. The phone asks the tester every time, and the video pauses while the TestForge form is on screen.
Crashes and freezes
Captured automatically: crashes with their stack trace, and freezes where the app stops responding for 5 seconds.
Steps and logs
The screens and buttons used before the report, failed requests, and console, network and app logs. Testers see a preview and choose what to attach.
A link to the report
After sending, the tester sees Report sent with Copy link and Share. The link opens the report for anyone in your workspace.
What you see in TestForge
- Bugs → Inbox. Every report lands in the Inbox tab of Bugs, beside your bug list, in the project you registered the app in. The Bugs menu item shows how many reports are open. Repeats of the same crash are grouped, with a count (
×3). - The From app badge and the build chip. Every report is marked as coming from your app, next to a chip naming the build it came from, for example
YourApp 1.4-test (1042) · Debug: the app, its version, the build number in brackets, and the build channel. - The App & build card. Inside a report: the app and its bundle ID, version and build number, channel, the web bundle version (hybrid apps), the SDK versions, the platform and OS, the device, the language setting and the screen orientation.
- Filters. Narrow the Inbox by status, Build channel and App version (the last two appear once reports come from more than one), by type (Bugs, Crashes, Freezes), or search by title, screen, device or version.
- Create bug. One click turns a report into a normal TestForge bug, with the steps filled in and the screenshot, replay, recording and logs attached. The bug keeps the From app badge and build chip in your bug list. Its Found in build field (labelled Build) holds the build chip, and Environment says
Tester build · Debug. A link leads back to the tester's report.
Reports are evidence for your team to look at. Nothing becomes a bug until someone on your team decides it should.
Before you start
The Mobile SDK feature must be on
The SDK is a TestForge feature called Mobile bug-report SDK (mobileSdk). It is off until it is switched on for your workspace. Turn on the bug tracker as well, so reports appear in Bugs → Inbox and can become bugs.
- On the hosted TestForge service: ask TestForge support to switch the Mobile SDK on for your workspace.
- On a TestForge server your company runs: a platform administrator turns on Mobile bug-report SDK under Admin → Settings → Feature flags, for everyone or for chosen organizations.
The SDK is not tied to a particular plan in the app: what matters is that the feature is on for your workspace. The people who read and triage reports use their normal TestForge seats. Testers who only send reports from the app don't need a TestForge account. See cost and limits.
Who does what
| Who | What they do |
|---|---|
| A workspace admin | Registers the app in TestForge and copies its key (step 1). Only organization administrators see the setup panel. |
| An app developer | Adds the SDK to a test build, asks for consent, and ships the build to testers (steps 2 and 3). |
| Testers | Install the test build and shake to report. They need no TestForge account. |
| Your QA team | Reads reports in the Inbox, creates bugs, and shares links. Opening a report needs a TestForge sign-in to your workspace. |
Supported platforms
| Your app | Support | What you get |
|---|---|---|
| Native Android Views, AppCompat, Jetpack Compose | Full · 0.2.4 | Everything on this page: report form, screenshot with drawing, visual replay, screen recording, crashes, freezes, app log, report link. Android 6 (API 23) or later; WebView capture needs Android 8; OS crash and freeze records need Android 11. |
| Hybrid on Android Capacitor or another WebView app | Full · 0.2.4 | Everything above, plus readable web steps (routes, button names, forms) and the web console and network logs. Uses the Android SDK and the JavaScript SDK together. |
| Web app or PWA | JavaScript · 0.2.4 | JavaScript errors, freezes of the page, readable steps, console and network logs, and a simple web report form. No screenshots, screen recording or visual replay. |
| iOS | Early · 0.1 | Crashes and freezes (delivered by iOS, often a day later), shake to report with a simple title-and-message form, and screen names you send. No screenshots, recording, logs or report link yet. In a hybrid iOS app the JavaScript SDK runs on its own. See iOS status. |
Set up in five steps
Plan about an hour for a native Android app, a little longer for a hybrid app. Examples use com.example.app and a placeholder key; use your own.
-
Register your app in TestForge and copy the key
- Sign in to TestForge as a workspace admin and choose the project the reports should go to.
- Open Bugs → Inbox and expand SDK apps and installation at the top of the Inbox. This setup panel is where you register apps and manage their keys.
- Enter an App name, the Bundle or package ID (for example
com.example.app) and the SDK platform:android,ios,web, or All platforms. - Click Register SDK app.
- Click Copy SDK key and keep the key somewhere safe for your developer. It is shown only once. Copy the SDK endpoint shown below it too; it looks like
https://testforge.example.com/api/public/mobile-sdk/events.
TipPick the platform your app reports as. A hybrid app whose web layer can also run in a browser, or one key shared by several builds, should be All platforms. A mismatch makes TestForge reject the reports (see Troubleshooting).
The key is
tfm_followed by exactly 43 characters. Copy all of it: from version 0.2.4 the SDK checks the length when your app starts and refuses a shortened key with the messagetfm_ followed by 43 characters
. The key can only send reports for this one app; it can't read anything (more about the key). From the same panel you can Rotate key (the old key stops working at once) or Revoke it. A project can have up to 25 registered apps. -
Install the SDK
The SDK comes as source packages: an Android library module and a JavaScript package. It is not on Maven Central or the public npm registry yet. Ask TestForge support for the 0.2.4 SDK files, copy them into your repository (for example under
third_party/testforge-mobile/), and keep both parts at the same version.Requirements: minSdk 23, Java 11, compileSdk 36. The module brings OkHttp 4.12.0 with it.
settings.gradle.ktsinclude(":testforge-mobile") project(":testforge-mobile").projectDir = file("third_party/testforge-mobile/android")app/build.gradle.ktsandroid { buildFeatures { buildConfig = true } defaultConfig { buildConfigField("String", "TESTFORGE_ENDPOINT", "\"https://testforge.example.com/api/public/mobile-sdk/events\"") buildConfigField("String", "TESTFORGE_KEY", "\"tfm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"") } buildTypes { getByName("debug") { buildConfigField("boolean", "TESTFORGE_TEST_BUILD", "true") } getByName("release") { buildConfigField("boolean", "TESTFORGE_TEST_BUILD", "false") } } } dependencies { implementation(project(":testforge-mobile")) }One fix in the copied module: its
build.gradlepins the Android Gradle Plugin so it can build on its own. Inside your project, remove the version so Gradle uses yours:plugins { id 'com.android.library' }.A Capacitor app uses both parts. Add the Android module exactly as on the Android tab, then install the JavaScript package in your web project:
npm install ./third_party/testforge-mobile/jsIt installs as
@testforge/mobile-sdk, with TypeScript types and no dependencies. The two parts are connected in step 3 and in Hybrid apps.npm install ./third_party/testforge-mobile/jsIt installs as
@testforge/mobile-sdk, an ES module with TypeScript types and no dependencies. It needsfetchand the browser'scrypto, which every current browser has.Because the SDK sends reports from the page, the TestForge server must accept your page's web address (its origin, for example
https://app.example.com). Send it to TestForge support, or to whoever runs your TestForge server, before you test. -
Initialise the SDK and ask for consent
Create one SDK client when the app starts. Nothing is collected until your app turns consent on, after the user has said yes. The SDK doesn't remember that choice, so store it yourself and turn consent on again at each launch.
App.ktpackage com.example.app import android.app.Application import android.content.Context import com.testforge.mobile.TestForgeConfig import com.testforge.mobile.TestForgeMobile class App : Application() { lateinit var testForge: TestForgeMobile private set override fun onCreate() { super.onCreate() val test = BuildConfig.TESTFORGE_TEST_BUILD val config = TestForgeConfig.Builder() .visualReplay(test) // masked replay of the last seconds .screenRecording(test) // needs the manifest entries below .appLogs(test) // your app's own log lines .build() testForge = TestForgeMobile(this, BuildConfig.TESTFORGE_ENDPOINT, BuildConfig.TESTFORGE_KEY, BuildConfig.VERSION_NAME, config) // Consent starts off at every launch: restore the user's choice. if (ConsentStore.isGranted(this)) testForge.setConsent(true) } } // Your own storage for the choice. Don't use "testforge_mobile_prefs": // the SDK clears that file when consent is turned off. object ConsentStore { private const val FILE = "diagnostics_consent" fun isGranted(c: Context) = c.getSharedPreferences(FILE, Context.MODE_PRIVATE).getBoolean("granted", false) fun set(c: Context, granted: Boolean) = c.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit().putBoolean("granted", granted).apply() }When the user answers your consent promptfun onConsentChosen(activity: Activity, granted: Boolean) { ConsentStore.set(activity, granted) val sdk = (activity.application as App).testForge sdk.setConsent(granted) if (granted) sdk.attach(activity) // the screen that is already open }App.javapackage com.example.app; import android.app.Application; import android.content.Context; import com.testforge.mobile.TestForgeConfig; import com.testforge.mobile.TestForgeMobile; public class App extends Application { private TestForgeMobile testForge; @Override public void onCreate() { super.onCreate(); boolean test = BuildConfig.TESTFORGE_TEST_BUILD; TestForgeConfig config = new TestForgeConfig.Builder() .visualReplay(test) .screenRecording(test) .appLogs(test) .build(); testForge = new TestForgeMobile(this, BuildConfig.TESTFORGE_ENDPOINT, BuildConfig.TESTFORGE_KEY, BuildConfig.VERSION_NAME, config); if (ConsentStore.isGranted(this)) testForge.setConsent(true); } public TestForgeMobile testForge() { return testForge; } } final class ConsentStore { private static final String FILE = "diagnostics_consent"; static boolean isGranted(Context c) { return c.getSharedPreferences(FILE, Context.MODE_PRIVATE).getBoolean("granted", false); } static void set(Context c, boolean granted) { c.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit().putBoolean("granted", granted).apply(); } }When the user answers your consent promptvoid onConsentChosen(Activity activity, boolean granted) { ConsentStore.set(activity, granted); TestForgeMobile sdk = ((App) activity.getApplication()).testForge(); sdk.setConsent(granted); if (granted) sdk.attach(activity); }For a web app. In a Capacitor app, start the web layer as shown in Hybrid apps, in addition to the Android code.
src/testforge.tsimport { createTestForge } from '@testforge/mobile-sdk'; export const testForge = createTestForge({ endpoint: 'https://testforge.example.com/api/public/mobile-sdk/events', apiKey: 'tfm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', appVersion: '1.4.0', platform: 'web', persistQueue: true, // keep unsent reports across reloads (only while consent is on) }); const CONSENT = 'diagnostics-consent'; if (localStorage.getItem(CONSENT) === 'yes') testForge.setConsent(true); export async function allowDiagnostics() { localStorage.setItem(CONSENT, 'yes'); testForge.setConsent(true); await testForge.enableShake(); // optional; call it from the click handler } export function stopDiagnostics() { localStorage.removeItem(CONSENT); testForge.setConsent(false); // stops capture and deletes unsent reports }Register the class in
AndroidManifest.xmlwith<application android:name=".App" …>. Suggested consent wording: "Send diagnostic reports to the developers: crash and freeze details, the screens and buttons you used, and, when you report a bug, a screenshot with text fields blacked out."Screen recording (Android)
Recording is off unless you turn on
screenRecording(true)and add these permissions. Put them insrc/debug/AndroidManifest.xmlso they only exist in test builds:app/src/debug/AndroidManifest.xml<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> </manifest>Without the first two, Record screen doesn't appear. The notification permission is optional; without it the notification has no Stop button, and testers stop with the floating Stop recording pill instead.
-
Send a test report
Install the test build on a real phone, accept your consent prompt, then shake the phone twice. Or give testers a button:
reportButton.setOnClickListener { testForge.requestReport() }reportButton.setOnClickListener(v -> testForge.requestReport());reportButton.addEventListener('click', () => testForge.showReporter());On Android the report form opens with a screenshot. Draw on it, type a Title or What happened?, pick a Severity, optionally open Attach logs to see exactly what will be sent, and tap Send. After a few seconds the form shows Report sent with a link, Copy link and Share.
On the web,
showReporter()opens a small form with Summary, What happened?, Attach logs and Send report. -
Find it in the Inbox
In TestForge, open Bugs → Inbox in the project you registered the app in. Your report is at the top with the From app badge and its build chip, for example
YourApp 1.4-test (1042) · Debug. Open it to see the screenshot, the steps and the logs. Or open the link from Report sent on a computer where you're signed in.Nothing there after a minute? Go to Troubleshooting. The most common causes are consent not turned on, a key that was rotated, or the wrong platform.
Use it only in test builds
The SDK is built for testers using test builds of your app. Keep it, and especially screenshots, recording and logs, out of the builds you publish to the stores.
- Switch the rich capture on by build type. The
TESTFORGE_TEST_BUILDflag from step 2 istruein debug builds andfalsein release builds, and the config in step 3 uses it for visual replay, recording and app logs. - Or don't create the client at all in store builds. If no client exists, nothing is captured or sent and shake does nothing:
var testForge: TestForgeMobile? = null if (BuildConfig.TESTFORGE_TEST_BUILD) { testForge = TestForgeMobile(this, BuildConfig.TESTFORGE_ENDPOINT, BuildConfig.TESTFORGE_KEY, BuildConfig.VERSION_NAME, config) } - Keep recording permissions in
src/debug. Google Play asks every app that declaresFOREGROUND_SERVICE_MEDIA_PROJECTIONto explain it in the Play Console. If the permission is only in test builds, your store listing is unaffected. - Leave the JavaScript SDK out of production bundles. Load it with a dynamic
import()behind a build-time variable (see Hybrid apps). After a production build,grep -ri testforge dist/should find nothing but your own wrapper. - Give every test build a new version code. It's the build number in the brackets of the build chip, so reports are easy to tell apart in the Inbox. It also matters for apps with live web updates (see below).
Label your builds with a channel
The build channel says how a build reached your testers, for example Debug, Play internal, Firebase or TestFlight. It appears at the end of the build chip, on the App & build card and on bugs created from a report (Environment: Tester build · Play internal). You can also filter the Inbox by it. That makes it quick to tell this morning's internal build from last week's debug build, and to look at one channel's reports only.
- Android sends the channel
Debugon its own for debuggable builds, and the build number (yourversionCode) on every report. Set a channel for other builds withbuildChannel(…). An empty string sends none. - Web and hybrid pages send nothing by default. Pass
buildChanneland, for a plain web app,buildNumber. In a hybrid app the Android layer already sends the build number. - Channels are short labels: letters, digits, spaces and
. _ + -, up to 32 characters. Don't put personal data in them.
val config = TestForgeConfig.Builder()
.buildChannel("Play internal") // default: "Debug" for debuggable builds
.visualReplay(test)
.build()TestForgeConfig config = new TestForgeConfig.Builder()
.buildChannel("Play internal") // default: "Debug" for debuggable builds
.visualReplay(test)
.build();createTestForge({
endpoint: 'https://testforge.example.com/api/public/mobile-sdk/events',
apiKey: 'tfm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
appVersion: '1.4.0',
buildChannel: 'Staging', // shown on reports and filterable in the Inbox
buildNumber: '1042', // web apps; in a hybrid app Android sends it
});Every user must agree first: the SDK sends nothing without consent. Tell users what is collected, declare it in Google Play's Data safety form and Apple's privacy details, and keep visual replay, recording and logs off.
Hybrid apps (Capacitor and WebView)
A hybrid app has two layers, and the SDK has a part for each. The Android SDK owns the report form, screenshots, recording, crashes and uploads. The JavaScript SDK runs in the page and adds readable steps, web logs, and the positions of fields to black out.
Connect the web layer
First do steps 2 and 3 for Android, then turn on WebView capture in test builds:
val config = TestForgeConfig.Builder()
.visualReplay(test)
.allowWebViewCapture(test) // show the page, black out what the page reports
.screenRecording(test)
.appLogs(test)
.build()Without allowWebViewCapture(true) the whole page is black in every screenshot, because form contents live in the page where Android can't see them.
Next, give your web code access to the native SDK with a small Capacitor plugin in your app. The SDK doesn't install one itself. The important line is attachWebView, called in load() before the page starts:
@CapacitorPlugin(name = "TestForge")
public class TestForgePlugin extends Plugin {
private TestForgeMobile sdk;
@Override public void load() {
sdk = ((App) getContext().getApplicationContext()).testForge();
if (sdk == null) return; // no SDK in this build
sdk.attachWebView(getBridge().getWebView()); // before the page loads
}
@PluginMethod public void getConsent(PluginCall call) {
JSObject out = new JSObject();
out.put("granted", sdk != null && sdk.hasConsent());
call.resolve(out);
}
@PluginMethod public void setConsent(PluginCall call) {
boolean granted = Boolean.TRUE.equals(call.getBoolean("granted", false));
ConsentStore.set(getContext(), granted);
if (sdk != null) {
sdk.setConsent(granted);
if (granted && getActivity() != null) sdk.attach(getActivity());
}
call.resolve();
}
@PluginMethod public void report(PluginCall call) {
if (sdk != null) sdk.requestReport();
call.resolve();
}
@PluginMethod public void diagnostics(PluginCall call) {
JSObject out = new JSObject();
out.put("text", sdk == null ? "TestForge off in this build" : sdk.diagnostics().toString());
call.resolve(out);
}
}Register it in MainActivity with registerPlugin(TestForgePlugin.class) before super.onCreate(…).
import { registerPlugin } from '@capacitor/core';
export interface TestForgeNativePlugin {
getConsent(): Promise<{ granted: boolean }>;
setConsent(options: { granted: boolean }): Promise<void>;
report(): Promise<void>;
diagnostics(): Promise<{ text: string }>;
}
export const TestForgeNative = registerPlugin<TestForgeNativePlugin>('TestForge');Start the web layer (test builds only)
import type { TestForgeMobile } from '@testforge/mobile-sdk';
import { TestForgeNative } from './native';
let web: TestForgeMobile | null = null;
export async function startTestForge() {
if (import.meta.env.VITE_TESTFORGE !== 'on') return null; // dropped from production builds
const { createTestForge } = await import('@testforge/mobile-sdk');
web = createTestForge({
endpoint: 'https://testforge.example.com/api/public/mobile-sdk/events',
apiKey: 'tfm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
appVersion: import.meta.env.VITE_APP_VERSION ?? 'dev',
// platform: detected from Capacitor ('android' in the Android shell)
});
const { granted } = await TestForgeNative.getConsent();
if (granted) web.setConsent(true);
return web;
}
// Both layers have their own consent switch: always change them together.
export async function setDiagnosticsConsent(granted: boolean) {
await TestForgeNative.setConsent({ granted });
web?.setConsent(granted);
}Call startTestForge() early in main.ts, before your router renders, and build test builds with VITE_TESTFORGE=on. You don't need to set platform: from version 0.2.4 the web SDK asks Capacitor, so in the Android shell it reports android and matches an app registered as android or All platforms. Set it yourself only to override that.
Capacitor pages on Android have the web address https://localhost. The TestForge server must accept it for the page's own reports and logs. If it doesn't, the web SDK keeps those reports on the phone and retries every few minutes, so nothing is lost: they arrive once the address is allowed. Ask support, or your TestForge server's operator, if web reports stay queued while native ones arrive.
Privacy tags for your pages
The page tells Android where its form fields are, and those areas are blacked out in screenshots and replay: input, textarea, select, editable elements, and the Ionic inputs (ion-input, ion-textarea, ion-searchbar, ion-select). Add tags for everything else:
<!-- Black out anything that shows personal data, not only inputs -->
<ion-list data-tf-private>…customer names…</ion-list>
<!-- Readable names for icon-only buttons, screens and forms -->
<ion-button data-tf-name="Add customer"><ion-icon name="add"></ion-icon></ion-button>
<h1 data-tf-screen="Customer list">Customers</h1>
<form data-tf-name="New customer">…</form>data-tf-privateblacks out the area, and taps and labels inside it are never recorded. Use it on lists of names, account pages, chat and payment details.data-tf-nameanddata-tf-screenmake the steps readable ("Tapped Add customer on Customer list"). Use fixed names, never customer data.
The live-update pitfall (Capgo and similar)
Over-the-air update plugins such as Capgo can replace your test build's web code with the production bundle from your update channel. That bundle doesn't contain the JavaScript SDK, so the page goes silent: every screenshot says Page hidden: TestForge web layer not connected
and web logs are missing.
- Disable live updates in test builds, for example with a test-only Capacitor config that turns the updater's automatic updates off, or by not installing the updater in test builds. Or point test builds at a separate channel that serves test bundles.
- Or reset to the built-in bundle when a test build starts (Capgo:
CapacitorUpdater.reset()). - Use a new version code for every test build, so the updater doesn't keep an old downloaded bundle across installs.
Check your update plugin's documentation for the exact option names in your version.
Check that both layers are connected
Put a hidden row in your test build's settings that shows TestForgeNative.diagnostics() and refreshes when tapped. When everything is connected, the bridge line shows MASKS_FRESH and the web SDK version:
TestForge 0.2.4 · consent on
Web bridge: MASKS_FRESH · 1 WebView(s) · js/0.2.4 · hello 12.4 s ago
Capture: PixelCopy · ok · 3.1 s ago · black 2%
Queue: 0 events · 0 mediaATTACHED with no js/… version means the web layer never connected. See Troubleshooting.
Privacy and consent
The SDK starts switched off, collects only what's listed below, hides what people type, and keeps detailed data for 14 days.
Consent
- Nothing is collected until your app calls
setConsent(true)after the user agrees. setConsent(false)stops everything, cancels uploads, and deletes unsent reports, media and history from the device.- In a hybrid app, the native and web layers each have a consent switch, and both must be on.
- Turning consent off can't recall reports that TestForge already received. A workspace admin can delete them (Delete telemetry in the setup panel removes an app's reports and media).
What is collected
| Data | When | Default |
|---|---|---|
| Crash type and stack trace (never the error message) | The app crashes | On |
| Freezes of 5 seconds or more | The app is on screen | On |
| Steps: screen names, button names, taps, failed requests | While consent is on | On |
| The tester's title, description, expected and actual result, severity | The tester sends a report | On |
| Screenshot (Android), masked, with the tester's drawing | The report form opens | On; the tester can untick it |
| Visual replay (Android): about 1.5 masked images per second, the last 30 seconds attached | While the app is on screen | Off: visualReplay(true) |
| Screen recording (Android), no audio, up to 5 minutes | The tester starts it and accepts Android's prompt | Off: screenRecording(true) |
| Console log (web) and network log: method, address without query, status, timing | While consent is on | On in the web SDK |
| App log (Android): your own app's log lines, last 5 minutes | Reports, freezes, the launch after a crash | Off: appLogs(true) |
| Request and response contents | Network log entries | Off: captureBodies |
Never collected: what people type, the keyboard, the clipboard, cookies, passwords and Authorization headers, query strings in addresses, audio, the camera, location, advertising IDs, user IDs or device IDs. Android includes the device model, which doesn't identify a single device. Testers see a preview of every log before they send it.
Masking
Masking happens on the phone before an image is saved; masked pixels never leave the device.
- Text and password fields are always blacked out.
- Private views: tag a view
testforge_private(Kotlin:view.tag = "testforge_private") to black it out with everything inside it. - Protected screens: screens your app marks as secure (
FLAG_SECURE) are never captured. - Jetpack Compose screens are blacked out unless you tag the
ComposeViewtestforge_capture, because the SDK can't find Compose text fields. - Web pages are blacked out unless you turn on WebView capture and connect the JavaScript SDK; then only fields and
data-tf-privateareas are hidden. If the page stops reporting its fields, the whole page is blacked out again.
A screen recording shows whatever is on screen, without masks: other apps, notifications and the keyboard. That's why it's off by default and Android asks every time. Ask testers to use test accounts while recording.
Text that is sent (titles, steps, logs) is cleaned on the phone and again on the server: email addresses, bearer tokens and values such as password= or token= are replaced. This is best effort, so never put personal data into screen names, button names or log messages.
How long data is kept
| Where | What | How long |
|---|---|---|
| On the phone | Unsent reports | Until sent; at most 20 reports and 7 days |
| On the phone | Visual replay | In memory only, the last 60 seconds. After a crash, a short copy is kept privately and sent on the next launch. |
| In TestForge | Report details: screenshots, replay, recordings, logs, steps | 14 days, then deleted automatically |
| In TestForge | Report group summaries and links to bugs | Until you delete them |
| In TestForge | Bugs created from reports | Like any bug. Create bug copies the media onto the bug, so it stays after the 14 days. |
The key is write-only
- The
tfm_…key can only send reports for the one app it was created for. It can't read reports, your Inbox, projects or anything else. - It isn't a secret: anyone who unpacks your app can read it. That's expected. Reports are treated as untrusted evidence, rate limits apply, and your team decides what becomes a bug.
- TestForge stores only a fingerprint of the key, which is why it's shown once. Rotate key issues a new one and stops the old one at once; ship the new key in your next build.
- Never put a TestForge password, session or personal API key into an app.
Reading a report
Open Bugs → Inbox and select a report. Everything the tester sent is on one page. On a phone, the list and the report are separate views with a back arrow.
The Inbox list
Reports are grouped by day. Each row shows the type (bug, crash or freeze), the title, the screen, device and version, the From app badge with the build chip, a status (New, Open, Resolved, or the linked bug such as B12), the time, and ×N for repeats. Use search, Report status, Build channel, App version and the type chips to narrow the list. Session replays without a report only appear under Replays.
Summary, app and build
The top of a report shows the badge, severity, status, type and date, then the title, the build chip, what happened, and the expected and actual results. The App & build card lists the app and bundle ID, version and build number, channel, web bundle, SDK versions, platform and OS, device, language and orientation. Other occurrences switches between repeats, so you can compare devices and builds. Crashes also show the Stack trace.
Video, replay and pauses
- If the tester recorded the screen, the recording plays at the top. On its scrubber, dashed stretches mark where TestForge's own form was on screen. Those parts were paused and aren't in the video, and chips underneath list the pauses. A red mark shows the report moment.
- Without a recording, the visual replay plays the masked images from the last seconds before the report or crash, with the report moment marked. Without either, you see the screenshot exactly as the tester drew on it. With a recording, the screenshot sits beside it as a thumbnail.
- Beside the player: the device, OS, app version and screen, and a highlight of failed requests and errors in the 30 seconds before the report.
What led up to it
One timeline of the steps in plain words: screens, taps, form submits, dialogs, slow or failed requests and error logs. Select an entry to jump the video or replay to that moment. Raw breadcrumbs on the App & build card shows the underlying data.
Logs: sections and filters
Logs are split into sections, each with a count: Errors & warnings (open first), Network (with the number of failed requests), Console and App log. Each section has level chips and a search box, and Network has Failed requests only. Repeated lines are folded with a count. Download HAR and Download console (.txt) export the logs for your developers' tools.
Create a bug
Summarize steps with AI (in the ⋯ menu) drafts steps to reproduce, expected and actual results with your workspace's AI assistant. It uses the text only, never images, video or stack traces. Edit the draft, then click Create bug. The bug gets the steps, copies of the screenshot, replay, recording and logs, the From app badge and build chip, Found in build and Environment, and a link back to the report. Afterwards the button reads Open B12, and new reports of the same problem link to that bug.
You can also Resolve a report, or Ignore it from the ⋯ menu. A new occurrence reopens a resolved report; an ignored one stays ignored.
Share the link
Copy link and Share give you the same link the tester got on Report sent. It opens the report in the Inbox, or the bug once one exists, for anyone signed in to your workspace. It shows nothing to anyone else.
Troubleshooting
Messages testers can see on screenshots, in the report form, or in your diagnostics row, with what they mean and how to fix them.
| What you see | What it means | Fix |
|---|---|---|
Page hidden: TestForge web layer not connected | The web page's JavaScript SDK never started: it isn't in this build, its consent is off, or a live update replaced the web code. | Load the JavaScript SDK in test builds and turn on its consent together with native consent. Turn off live updates in test builds. |
Console log (not available: web layer not connected) | The same cause, seen under Attach logs. | As above. |
Page hidden: privacy masks from the page are out of date | The page stopped reporting where its fields are, usually because it was busy, reloading or paused. The reason follows in brackets. | Keep both SDKs on the same version (0.2.3 or later) and try the screenshot again. Avoid long-running work on the page's main thread. |
Page hidden: no privacy masks from the page yet | The web layer connected but hasn't sent field positions yet, often because native consent was off when the page started. | Turn native consent on before or together with web consent. Make sure every WebView runs the JavaScript SDK. |
Page hidden: WebView capture is off (allowWebViewCapture) | The default: web pages are hidden. | Turn on allowWebViewCapture(true) in test builds, with the JavaScript SDK in the page. |
Page hidden: attachWebView() was not called | WebView capture is on, but the WebView isn't connected. | Call attachWebView (Capacitor: in your plugin's load()). |
Page hidden: WebView capture needs Android 8 or later | The phone runs Android 7 or older. | Test on Android 8 or later. |
Compose content hidden (tag it testforge_capture to show it) | A Jetpack Compose screen was hidden, because its text fields can't be found. | If the screen is safe to show, tag the ComposeView testforge_capture; wrap sensitive parts in a view tagged testforge_private. |
| A black screenshot with no caption | The screen content wasn't in the app's window: video, maps, games or camera previews drawn on their own surface, or the screen was mid-transition. | Expected for those views. Try again once the screen has settled. |
No screenshot: this screen is protected or could not be captured. | The screen is marked secure, or the capture failed. | Expected on secure screens. The tester can still send the report. |
| A black screen recording | On Android 14 the tester recorded "a single app" and picked a different one, or the screen was secure or showed protected video. | Keep the default whole-screen recording. Secure screens are always black in recordings. |
| 0 console or network lines | Nothing was logged since consent was turned on, logs are turned off, or the page calls the network through a native plugin instead of fetch. | Check that consent is on early. Native network calls appear only through the SDK's OkHttp interceptor on your own client. |
| App log missing under Attach logs | appLogs is off. | Turn on appLogs(true) in test builds. |
Record screen missing, or Screen recording needs FOREGROUND_SERVICE and FOREGROUND_SERVICE_MEDIA_PROJECTION… | screenRecording is off, or the manifest permissions are missing. | Add the permissions and turn on screenRecording(true). |
Saved on this device. It will be sent when the connection is back. | The phone is offline or TestForge is busy. | Nothing to do: the report is sent automatically when the connection returns. |
Reporting is turned off. | Consent was off when the tester pressed Send. | Check your consent flow turns consent on at launch. |
Add a title or describe what happened | Both fields are empty. | Type a title or a description. |
| Reports never reach the Inbox | The key was rotated or revoked, the endpoint is wrong, or the Mobile SDK feature is off for your workspace. When the key itself is rejected, the SDK discards its unsent reports, because retrying can't help. | Check the key and endpoint against the setup panel, and that the feature is on. |
| Web reports stay queued, Android reports arrive | The server doesn't accept the page's web address (origin). The web SDK keeps those reports and retries every few minutes. | Ask support to allow your page's origin (https://localhost for Capacitor on Android). The queued reports then arrive by themselves. |
| Web reports rejected as the wrong platform | The app is registered for a different platform than the page reports. Web SDKs before 0.2.4 always said web, even inside Capacitor. | Update to 0.2.4, which detects Capacitor, or register the app as All platforms. |
The app stops at start with tfm_ followed by 43 characters | The key in your build is shortened or mistyped. Since 0.2.4 the SDK checks it when the app starts. | Copy the whole key from SDK apps and installation. If it's lost, Rotate key and ship the new one. |
| A crash report is missing | Crash reports are sent on the next launch. | Open the app again after the crash. |
| A freeze isn't reported | Freeze detection doesn't run while a debugger is attached. | Test without the debugger. |
FAQ
What does it cost, and what are the limits?
The SDK isn't counted against your seats: testers who only send reports need no TestForge account, and the people who read reports use their normal seats. Whether the feature is included for your workspace is set by TestForge; ask support if you don't see the setup panel.
Limits per registered app or key:
- 300 reports per hour per key, and 120 per minute from one network address;
- 10,000 stored reports and 500 MB of media per app (media is deleted after 14 days, which frees space);
- per report: screenshot 2 MB, visual replay 10 MB, recording 50 MB (and 5 minutes), logs 5 MB;
- 25 registered apps per project.
What's the status of iOS?
The iOS package is an early version, 0.1. It captures crashes and freezes (crash reports are delivered by iOS itself, often a day later), supports shake to report with a simple title-and-message form, and records the screen names and steps you send. It doesn't have screenshots, drawing, visual replay, screen recording, logs, media upload or the report link yet. In a Capacitor app on iOS, the JavaScript SDK works on its own, without the native layer. Requires iOS 14 or later.
Where is the data stored?
Reports go straight from the phone to the TestForge service your workspace uses, at the endpoint shown in the setup panel.Screenshots, recordings and logs are stored as private files of your organization: only signed-in members of your workspace can open them, never the SDK key or an anonymous visitor. Report details aren't included in backups and are deleted after 14 days. Summarize steps sends only the report text and steps to your workspace's AI provider.
What happens when the phone is offline?
The report is saved on the phone and the form says Saved on this device. It will be sent when the connection is back.
The SDK retries by itself and, on Android, shows a notification when the report is sent. Up to 20 unsent reports are kept for up to 7 days. Crashes are always saved and sent on the next launch. In a web app, unsent reports survive a reload only with persistQueue: true.
Do testers need a TestForge account?
No. Sending a report needs only the test build. Opening the report link needs a TestForge sign-in to your workspace, so testers without an account can share the link with someone who has one.
Can we leave it in our store builds?
It's designed for test builds, and we recommend keeping it there. If you do ship it, the SDK still sends nothing without each user's consent, and you must declare the data in the app stores' privacy forms.
Our Android release build is obfuscated. Will stack traces be readable?
Stack traces arrive as they were on the phone, so obfuscated builds give obfuscated traces. TestForge doesn't upload or apply mapping files; keep mapping.txt for each build. Test builds are usually not obfuscated.
Still stuck?
Send a message from How To → Contact support inside TestForge, or use the support page. Include your workspace, the app's package ID, the SDK version and your diagnostics row. Never include your key, passwords or customer data.