Firebase Analytics in Capacitor sent nothing
My Capacitor game had Firebase Analytics, Crashlytics and Performance wired in, and collected nothing. Four separate failures, none of which printed an error.
Contents
ASMR Logo Craft Block is a Capacitor game: React and Vite running in a WebView, wrapped by Capacitor 8 for Android and iOS. It is not an Ionic Framework app. Ionic the company makes Capacitor, which is why searches for firebase analytics ionic capacitor tend to land on threads about Ionic Angular projects, but nothing below depends on Ionic's UI layer. The native half is the same either way.
When I sat down to check the game's Analytics, Crashlytics and Performance setup, the answer was that it had collected nothing. Not a broken dashboard or a wrong filter. No events, no JavaScript errors, no traces. And at no point had anything said so: the build passed, the app ran, the logs were quiet.
There were four separate reasons, each silent in its own way.
The setup, for the versions: Capacitor 8.0.2, the @capacitor-firebase/* plugins at 8.5.2
(Crashlytics was on 8.1.0 before this; Analytics and Performance were not installed at all),
tested on an Android Pixel Tablet emulator and an iPhone 17 Pro simulator on iOS 26.1. The game
reaches Firebase through a set of shared SDK packages I use across my games, which matters
for the first failure.
One: the web SDK on a native app
Analytics went through the Firebase JavaScript SDK inside the WebView, on every platform, including Android and iOS. That SDK needs a web config, and the game's Firebase config file still held the template's placeholders:
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_AUTH_DOMAIN",
projectId: "YOUR_PROJECT_ID",
// …
};
The shared analytics wrapper checked for those placeholders and switched itself off rather
than throw on every launch. A kind default for a template nobody has configured yet. In a
game it meant every logEvent call went into a no-op, and no line anywhere said so.
Filling in the web config would not have been the fix. On a native build the web SDK would have reported the game as a website, under a different Firebase identity from the native Crashlytics. I never saw that path run, since the placeholders stopped it first, and it was not the path I wanted anyway. If your events show up from a browser and never from a phone, check which SDK they actually go through before anything else.
The fix was the native plugin. The @capacitor-firebase/analytics npm package, then
npx cap sync, and on Android and iOS events now go through it to the native Firebase SDK.
That chain is all a Firebase Analytics Capacitor setup is: the WebView calls the plugin, and
the plugin calls the same SDK a native app would use.
Two: a missing config file the build forgives
The native SDKs read their config from google-services.json on Android and
GoogleService-Info.plist on iOS. Neither was in the project. On Android, this is the block
that decides what happens then, in android/app/build.gradle:
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
Reading .text on a missing file throws, the catch swallows it, and the message goes out at
info, a level a normal Gradle build does not print. So the google-services and Crashlytics
plugins are skipped, the build succeeds, and the APK installs and runs. The one message that
exists talks about push notifications, which this game does not use, so even someone reading
--info output would not connect it to analytics.
iOS is no louder. The Crashlytics plugin checks for the plist at startup and, if it is absent, prints one line to the console and returns:
[FirebaseCrashlytics] Firebase was not configured: GoogleService-Info.plist is missing from the app bundle.
Once both files were in place the Android build ran the google-services and Crashlytics steps
for the first time. On iOS there is one more trap: the plist has to be a resource of the App
target, not just a file sitting in ios/App/App/. I wrote a small script to add it, and found
that the xcode npm library's addResourceFile assumes the project has a group named
Resources. A Capacitor project does not, so the script writes the file reference itself.
What stops this coming back is a check that runs before sync and before the Android and
iOS builds, and fails with one line per problem: a config file missing, a config file with no
entry matching the app's bundle ID or package name, the plist not in the target, no dSYM
upload step. It can still be skipped. It runs through the npm scripts, so building straight
from Xcode or Android Studio goes around it.
Three: JavaScript errors never reached Crashlytics
Native Crashlytics reports native crashes. In a WebView game, most of the code that can break
is JavaScript. A TypeError in a click handler or a rejected promise in the save code leaves the native
process perfectly healthy, and nothing in the game had installed a window error handler or
an unhandledrejection listener. A comment in the template said "an uncaught crash is
reported by Crashlytics itself", which is true for native crashes and for nothing the game's
code could actually throw.
Four: the errors that did arrive had no stack
Handled errors, the ones the game reported on purpose, had a quieter problem. The wrapper's
recordError sent only error.message, although the plugin's recordException takes a
stacktrace as well. A message with no frames tells you what broke and not where. And for
iOS there was no build step uploading debug symbols, so native crashes would have arrived
unsymbolicated.
Fixing three and four together, the capture now looks roughly like this:
import ErrorStackParser from 'error-stack-parser';
import { FirebaseCrashlytics } from '@capacitor-firebase/crashlytics';
const seen = new Set();
function report(label, err) {
const message = `${label}: ${err?.message ?? String(err)}`;
if (seen.has(message) || seen.size >= 10) return; // once each, ten per session
seen.add(message);
const stacktrace = err instanceof Error
? ErrorStackParser.parse(err).map(f => ({
fileName: f.fileName, lineNumber: f.lineNumber, functionName: f.functionName }))
: undefined; // a stack here would point at report()
FirebaseCrashlytics.recordException({ message, stacktrace });
}
window.addEventListener('error', e => report('uncaught', e.error ?? e.message));
window.addEventListener('unhandledrejection', e => report('unhandled rejection', e.reason));
The cap is there because an error inside a loop can fire on every frame, and the hundredth
copy of a report tells you nothing the first did not. A rejection with something other than
an Error gets the message only: building a stack for it would produce frames from inside the
reporter, which point the reader at the wrong file. The Vite build also sets
esbuild: { keepNames: true } so minified function names survive.
On the tablet, an uncaught error and an unhandled rejection I threw into the running game were
both recorded, labelled uncaught: and unhandled rejection:, and sent on the next launch. On
iOS a probe error was recorded as TypeError: uncaught: …. Those test errors had no function
names in their frames, because I injected them from outside the game's code. I have not yet
seen a real error from the game's own code arrive, so I cannot show you that keepNames does
its job in a report.
And one I nearly built in: record() is web only
Performance was not a silent failure, because it was not there at all. Adding it, I nearly
shipped a fifth. The @capacitor-firebase/performance plugin has a record() call
that takes a trace name, a start time and a duration in one go, which is exactly the shape a
JavaScript timer wants. Its type definition says "Only available on web." The native code
agrees:
public void record(PluginCall call) {
call.unimplemented("Not implemented on Android.");
}
The iOS side says "Not implemented on iOS.". So traces go through startTrace and
stopTrace, which do run natively. Reading those turned up the second catch:
public void startTrace(String traceName) {
Trace trace = this.getFirebasePerformanceInstance().newTrace(traceName);
trace.start();
this.traces.put(traceName, trace);
}
Traces are kept in a map by name. Start level_load while a level_load is already running
and the first one is replaced and never stopped. My wrapper now allows one trace per name at a
time: starting it again stops the earlier one, labelled superseded, so it shows up in the
console instead of vanishing.
The first device run then caught a trace measuring the wrong thing. One level_load on iOS
took 12.3 seconds. The trace started when a level was requested, and an interstitial plays
before the board appears, so the ad's run time was counted as load time. The trace now carries
an ad attribute, shown when an interstitial played on the way, so those loads can be
filtered out. That change is covered by tests; I have not looked at a labelled trace from a
device yet.
What arrived once it worked
To see what leaves the app, turn on Analytics debug mode and read the logs. On Android:
adb shell setprop debug.firebase.analytics.app <your.package.name>
adb logcat -s FA FA-SVC
On iOS, launch with -FIRDebugEnabled -FIRAnalyticsDebugEnabled as arguments.
I drove a full session on each device: a few levels played through, a hint and a shuffle used,
interstitials closed, settings and the shop opened. On the tablet the game logged app_open,
11 screen_view, 2 level_start, 2 level_complete, 16 category_solved, hint_used,
shuffle, the coin and hint earn_virtual_currency and spend_virtual_currency events, 7
ad_shown and 3 ad_unavailable. There were 66 uploads, and every one came back the same:
V FA-SVC : Successful upload. Got network response. code, size: 204, 0
The iOS simulator made 44 requests to the Analytics upload endpoint. iOS logs individual
events only at debug level, so I do not have a per-event list for it. Performance recorded
app_ready (533 ms and 396 ms on iOS), runtime_init, level_load and a trace per ad load
on both.
That is all measured at the device. It shows the data left and Firebase's server accepted it. It does not show what the console did with it, which is the next section.
"Only one event after an hour"
About an hour after those sessions I opened the Firebase console and saw one event. That reads like the integration failing again. It is what Google's documentation describes as normal:
- the standard Analytics reports can take up to 24 hours to process;
- Realtime shows only the last 30 minutes, so a session that ended an hour ago has already left it;
- DebugView shows events within seconds, but only from a device in debug mode.
I did not test any of that myself. What I know is the device side: the uploads got a 204, and a fresh session on the tablet with debug mode still on sent 8 more uploads, all accepted. The console side is the documentation's word, not my measurement.
Two more things worth checking, also from the documentation rather than from anything I hit. When one Firebase project holds more than one app, the app picker at the top of the Analytics pages has to be on the right one, or you are reading a different game's numbers. And a "developer traffic" data filter set to Active in Google Analytics drops events from devices in debug mode, which would leave DebugView empty however correct the app is.
What I did not measure
Startup cost. The debug APK grew from 29.5 MB to 31.6 MB, which I did measure. I tried to time cold start before and after, and the emulator swung from 1.2 s to 25 s between launches of the same build. The runs that were steadier hinted at a few hundred milliseconds slower, which is not a number I would put in front of anyone. The old build is also not a fair baseline: with no config file, Firebase never started in it at all.
The other thing that changes once Analytics really collects is paperwork. The Play Data safety form and the App Store privacy labels have to declare analytics, crash logs and performance data, and for players in the EU there is a consent question to settle before any of it starts. That is its own job and I will not fold it in here.
Takeaway
Every one of the four was a choice somebody made not to fail loudly: a wrapper that turns
itself off on placeholders, a Gradle catch that logs at info and a plugin that prints one
line and returns, a template that assumed Crashlytics would see JavaScript errors, a reporter
that sends the message and drops the stack. Each is reasonable on its own. Together they let a game run with a full Firebase integration that did nothing, and
there is no output to notice when nothing happens.
So the config check fails the build now, and I have stopped trusting a quiet log as evidence of anything. The way to know Firebase works in a Capacitor game is to turn on debug mode and count the 204s. I wrote about the same shape in a build verifier in The check that passed while broken: a check that cannot find anything looks exactly like one that found nothing wrong.