Logging & debugging
BGeo’s logging serves two different jobs. While you’re building your
integration, it’s a local diagnostic tool — getLog() in a debug screen,
Console.app, Xcode’s console. Once your app is in the field, it’s a server-first
diagnostic tool: the same log lines your users generate on their own devices
can be batched and uploaded to your backend, so you can see what happened on
a device you’ll never physically hold. That second job is the differentiator
— logging doesn’t wait for someone to reproduce a bug next to a debugger.
The native log store
Every engine event (motion-state changes, geofence transitions, upload
outcomes, filter decisions, and more) and every line your own code
writes through logger passes
through the same native logger. Two things always happen, unconditionally:
- The line is mirrored to
os_log— this happens regardless of any config, exactly like a normal platform log call. - If
logLeveladmits the line’s level, it’s also persisted as a row in the on-device log table (log_entriesinbgeo.db), where it becomes available togetLog()and eligible for upload vialogUrl.
logLevel defaults to 0 (OFF) — nothing is persisted or eligible for
upload out of the box, though the os_log mirror keeps working either
way. Raise it to capture more:
logLevel | Constant | Persists |
|---|---|---|
0 | logLevelOff | Nothing (default). |
1 | logLevelError | Errors only. |
2 | logLevelWarning | Warnings and above. |
3 | logLevelInfo | Informational and above. |
4 | logLevelDebug | Debug and above. |
5 | logLevelVerbose | Everything. |
See the constants reference for the full constant list.
Each persisted row is a LogEntry:
ts (ISO-8601 UTC), level (1–5), src ('native' or 'js' — the
tag is carried over unchanged from the shared native store, so app-written
rows still show up as 'js'), event (a short tag), and optional
message/data.
Writing app logs
Your own code logs through the same pipe as the engine, via
logger:
BackgroundGeolocation.logger.error("geofence sync failed", data: ["identifier": "home", "status": 500])BackgroundGeolocation.logger.warn("sync deferred", data: ["pendingCount": 3, "reason": "offline"])BackgroundGeolocation.logger.info("onboarding complete", data: ["step": "permissions"])BackgroundGeolocation.logger.debug("rehydrated config from storage")BackgroundGeolocation.logger.verbose("render tick", data: ["screen": "TrackMap"])Each call is persisted with src: "js" (native engine lines are src: "native"), alongside whatever data map you pass. Logging never
throws — every logger.* method returns Future<void> and swallows its
own failures, so a logging call can never crash or reject into your app code.
Reading logs locally
getLog({limit}) returns
persisted entries newest-first (default limit is 500):
let entries = await BackgroundGeolocation.getLog(limit: 200)for entry in entries { print("[\(entry.ts)] \(entry.src)/\(entry.event) \(entry.message ?? "")")}destroyLog() deletes every
persisted row and resolves with the count removed — useful for clearing the
table between test runs:
let removed = await BackgroundGeolocation.destroyLog()Uploading logs
Set logUrl to have the native
uploader batch persisted rows to your server. Without it, rows stay
local-only and are only reachable through getLog().
try await BackgroundGeolocation.ready( Config( logUrl: "https://your-server.example/logs", logLevel: LogLevel.info.rawValue, logMaxDays: 3 ))Batches of up to 100 rows are posted as:
{ "events": [ { "ts": "2026-07-24T11:24:03.512Z", "level": "info", "src": "native", "event": "motionchange", "message": "MOVING", "data": { "confidence": 82 } }, { "ts": "2026-07-24T11:24:07.118Z", "level": "warn", "src": "js", "event": "app", "message": "sync deferred", "data": { "pendingCount": 3, "reason": "offline" } } ]}level is uploaded as its name ("error" | "warn" | "info" | "debug" | "verbose"), not the numeric LogEntry value. App-written lines always carry
event: "app", with your message/data in message/data.
Log upload reuses the exact same headers, authorization, and
JWT token-refresh
machinery as location uploads — a killed-app log flush can refresh an
expired access token just like a location flush can. There’s one important
difference: onHttp fires only
for location-sync requests — it does not observe log uploads at all.
Rows flush on:
- a location-queue drain finishing (piggybacking on an already-warm connection),
- the periodic
heartbeat, - the app coming to the foreground,
- 100 pending rows accumulating,
- an explicit
uploadLog()call, and - while the app is in the foreground only, a 3-second trailing-edge coalescing timer that arms on each logged line — so a line logged by an idle foreground app reaches your server in a few seconds instead of waiting for the next heartbeat. This timer never fires in the background, so logging never wakes the radio on its own.
A 2xx or 4xx response marks the batch uploaded (rows remain available to
getLog() as local history until retention prunes them — an upload doesn’t
delete anything). A 429 or 5xx/network failure leaves the batch pending
for the next flush; 429 specifically means your server is throttling log
ingestion (BGeo’s own backend limits this to 30 requests/minute/device) —
it is not treated as a poison response the way a location 4xx is, so
the batch is retried rather than dropped.
Retention: persisted rows are kept for
logMaxDays (default 3),
on top of a hard 25,000-row cap that prunes the oldest rows regardless of
age.
uploadLog()
Trigger an out-of-band flush — for example, right after a user reports a problem, so you don’t wait for the next scheduled trigger:
let uploaded = await BackgroundGeolocation.uploadLog()Resolves with the number of rows handed to the flusher, which isn’t
necessarily the number successfully delivered — a 429/5xx batch stays
queued for the next trigger.
Debug sound cues
Setting debug: true plays a
one-shot audible cue on each of the following events, on both platforms. The
players below are the exact files the SDK ships, so you can learn the set
before you’re out in the field with a phone in your pocket. The
use different recordings for the same event — learn the column for the
platform you’re testing on.
| Event | Cue |
|---|---|
Location update — debug_location | |
Heartbeat — debug_heartbeat | |
Motion-change → moving — debug_motionchange_true | |
Motion-change → stationary — debug_motionchange_false | |
Stop-timeout armed — debug_stop_timeout_start | |
Stop-timeout cancelled (motion resumed) — debug_stop_timeout_cancel |
Geofence ENTER / EXIT / DWELL reuses the location cue, so a transition sounds exactly like an ordinary location update.
This is a development convenience only — it never affects tracking, filtering, or upload behaviour. The cues can be inaudible depending on the device’s silent-switch/audio-session state; treat them as a “something happened” signal rather than a guaranteed audible alert.
diagnosticExtras
diagnosticExtras attaches a
compact native diagnostic snapshot — fix counters, app and motion state, the
active manager configuration — into every uploaded location record’s
extras, rather than writing a log line.
It is meant for an instrumented test device while reproducing a field issue, not for a production fleet: it adds weight and noise to every location. It is also how the July 2026 background-delivery investigation was actually solved — per-point counter deltas showed CoreLocation delivering zero raw fixes during the gaps, which exonerated the filter and the upload queue in one reading.
Native-side inspection
Alongside getLog() and log upload, every line is still mirrored to the
platform’s own log system, so you can watch it live from a connected device
during development.
Xcode — the engine writes through os_log, so its lines appear in the
console alongside your app’s own output while debugging.
Console.app — connect the device and filter by subsystem dev.bgeo. This
is the one that matters for background work: it keeps receiving lines while the
app is backgrounded, suspended, or relaunched by iOS, none of which a debugger
session survives.
Web console
BGeo’s web console shows the device log
stream live — once a device is uploading through logUrl, its incoming log
rows appear in the console as they arrive, without you needing to pull them
off the device yourself.
Troubleshooting checklist
Still stuck? File a report with a getLog() dump spanning the
failure window alongside your ready() config
(redact secrets) and device/OS details — see the support page’s “Before
filing a bug” checklist for the full list.