Skip to content

feat: [SDK-5047] drive iOS logging from remote params - #1722

Merged
abdulraqeeb33 merged 8 commits into
mainfrom
ar/sdk-5047
Aug 25, 2026
Merged

feat: [SDK-5047] drive iOS logging from remote params#1722
abdulraqeeb33 merged 8 commits into
mainfrom
ar/sdk-5047

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

Summary

iOS already parsed logging_config.log_level from ios_params, but never used it to drive the logging lifecycle — so remote crash/OTel logging couldn't be enabled or adjusted server-side the way it can on Android. This ports Android's model and lifecycle, then fixes three pre-existing defects in the KMP adapter that surfaced once the backend actually started enabling logging.

Two commits, independently reviewable:

feat: — config + lifecycle port. Adds OSRemoteLoggingConfiguration (Android's RemoteLoggingConfigModel: persisted level + enabled flag derived from the server sending a valid level) and OSRemoteLoggingConfigEvaluator (Android's OtelConfigEvaluator: pure old-vs-new diff yielding Enable / Disable / UpdateLogLevel / NoChange). The controller applies those actions rather than only filtering log lines. The cache also stops round-tripping through a synthesized params-shaped dictionary, so it no longer depends on the params API response shape — relevant while SDK-5046 is changing that shape.

fix: — KMP boundary. Kotlin/Native only allows calling exported suspend functions from the main thread, but all three crossings were reached from the controller's serial queue and from URLSession callbacks; they're now marshalled to main. telemetry.shutdown()'s five-second runBlocking drain was reachable from initialize: and app-id changes via stateQueue.sync and could stall the UI, so the drain is deferred. And the HTTP exporter's diagnostics logged through OneSignalLog, whose sink is invoked unconditionally, so at verbose levels each export POST generated a new record and guaranteed another POST.

Behavior changes worth a close look

  • NONE now means enabled-but-exporting-nothing, matching Android's hydrate path, so the crash handler stays armed instead of collapsing to fully disabled.
  • A level change rebuilds the logger, as Android does in startLogging. This isn't just parity: the platform provider handed to KMP is built alongside the logger, so reusing the instance would keep reporting the previous level into KMP and would never re-run the crash uploader after a level escalates away from NONE.
  • start() now rejects once shutdown has begun, not once it has finished. Required by the deferred drain — otherwise a logger told to shut down could still install a crash handler nothing would unregister, permanently blocking every later logger from installing its own.

Verification

Verified end to end on the demo app against a real app id returning log_level: VERBOSE:

  • Config parsed to VERBOSE/enabled; evaluator returned Enable(VERBOSE); cache round-trip preserved both fields.
  • Remote records confirmed arriving at log-ingestion-service, /sdk/log returning 202.
  • Uncaught NSException captured by OSLogCrashHandler, persisted durably (7,861-byte .otlp), and uploaded by LogCrashUploader on the following launch. Confirmed server-side.

Tests: 18 OSRemoteLoggingControllerTests + 67 OneSignalOSCoreTests, all passing. Includes a verbatim ios_params payload fixture, the full evaluator transition matrix, and direct lifecycle tests for the start-after-shutdown guard.

This went through an adversarial multi-model review before opening; the critical finding (the start() guard) and three warnings were fixed as a result.

Test plan

  • CI green, including swiftlint and the Catalyst KMP logger host
  • Confirm a mid-session level change (e.g. ERRORVERBOSE) rebuilds the logger and the new level reaches KMP
  • Confirm NONE keeps crash capture armed while exporting nothing
  • Confirm cold start from cache arms the crash handler before the params call returns

Follow-ups filed separately

  • SDK-5058 — export batches near 128 KB rejected with 403 and silently dropped
  • SDK-5059 — isOneSignalAtFault drops SDK crashes when the OneSignal frame is elided

Not included

The KMP submodule pointer is deliberately left off this branch; local testing ran against a newer KMP commit than main pins, and CI will use main's.

Made with Cursor

AR Abdul Azeez and others added 2 commits August 21, 2026 13:46
iOS parsed logging_config.log_level but never used it to control the logging
lifecycle, so remote crash/OTel logging could not be turned on or adjusted
server-side the way it can on Android.

Adds OSRemoteLoggingConfiguration (Android's RemoteLoggingConfigModel: a
persisted level plus an enabled flag derived from the server sending a valid
level) and OSRemoteLoggingConfigEvaluator (Android's OtelConfigEvaluator: a
pure old-vs-new diff yielding Enable/Disable/UpdateLogLevel/NoChange). The
controller now applies those actions instead of only filtering log lines.

Two behavioral consequences worth noting. NONE now means enabled-but-exporting
-nothing, matching Android's hydrate path, so the crash handler stays armed
rather than collapsing to fully disabled. And a level change rebuilds the
logger, as Android does in startLogging, because the platform provider handed
to KMP is built alongside the logger; reusing the instance would keep
reporting the previous level into KMP and would never re-run the crash
uploader after a level escalates away from NONE.

The cache also stops round-tripping through a synthesized params-shaped
dictionary and owns its own format, so it no longer depends on the shape of
the params API response.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three pre-existing defects in the KMP adapter, all found while verifying the
remote-logging lifecycle end to end.

Kotlin/Native only permits calling exported suspend functions from the main
thread, but all three crossings (LogLoggingHelper.log, ILogTelemetry
.forceFlush, LogCrashUploader.start) were reached from the controller's serial
queue and from URLSession callbacks, which trips a runtime failure as soon as
the backend enables logging. They are now marshalled to main. The hop runs
inline when already on main so a caller that blocks waiting on the completion
cannot deadlock itself.

telemetry.shutdown() drains buffered records under runBlocking with a five
second cap, and was reachable from initialize: and from app-id changes through
stateQueue.sync, so a slow network could stall the UI for that long. Only the
drain is deferred to a background queue; unregistering the crash handler stays
synchronous, because OSLogCrashHandler.initialize() bails when another handler
is still registered and deferring it would silently disable crash capture
after an app-id change. Because the drain is now asynchronous, start() also
has to reject once shutdown has begun rather than once it has finished --
otherwise a logger told to shut down could still install a crash handler that
nothing would ever unregister, permanently blocking later loggers.

Finally, the HTTP exporter's diagnostics logged through OneSignalLog, whose
sink is invoked unconditionally, so at verbose levels each export POST created
a new exported record and guaranteed another POST. They now log console-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@onesignal-deploy

Copy link
Copy Markdown
Collaborator

Multi-model review found a few issues worth addressing:

  • forceFlush() can enter KMP concurrently with async shutdown. Please lifecycle-gate or serialize it, while always invoking completion.
  • Cached is_enabled: true with an invalid or missing level evaluates to ERROR, but the controller starts with nil. Please normalize or apply the evaluator's fallback.
  • An active crash upload can continue after shutdown begins.
  • Remove the literal 10| and 20| artifacts from the new file's license header.

Also worth confirming the intended privacy behavior: NONE suppresses exports but still installs the process-wide crash handler.

AR Abdul Azeez and others added 2 commits August 21, 2026 15:09
Serializes forceFlush against teardown. The deferred drain in shutdown() also
flushes the same telemetry, so entering KMP from both at once was unsafe; the
flush is now skipped once teardown begins. Its completion still runs in every
case, because callers end a UIBackgroundTask in it and swallowing it would leak
that task. The gating predicate is renamed isActive and now covers emission,
uploader start, and flushes alike, since all three cross the same boundary.

Normalizes a cached config that claims to be enabled without a level this SDK
can parse — possible when a newer build wrote the cache. Android falls back to
ERROR when enabling without a level; matching that avoids starting a logger
that is enabled yet can never export and reports no level into KMP.

Also strips line-number artifacts that leaked into the new file's license
header, and splits the test file's configuration cases into their own suite so
the controller suite stops exceeding the type_body_length error threshold. That
was the sole error-severity swiftlint violation in the repo and the reason the
lint job failed; the remaining violations in these files are file_length
warnings, which the linter tolerates.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Thanks — all four addressed in 86b7363, plus the lint failure. Details and one thing I could only partly fix:

forceFlush() entering KMP concurrently with async shutdown. Gated now. shutdown()'s deferred drain flushes the same telemetry, so the explicit flush is skipped once teardown has begun. The completion still fires on every path, including the skipped one — flushForLifecycle ends a UIBackgroundTask in it, so swallowing it would leak the task. While doing this I renamed the predicate from canEmit to isActive, since it now gates emission, uploader start, and flushes alike; they all cross the same boundary, and having three names for one condition was inviting the next bug.

Cached is_enabled: true with an invalid or missing level. Normalized at the source rather than at the consumer: init(cached:) now falls back to ERROR when enabled without a parseable level, matching Android's logLevel ?: ERROR. That keeps the stored config self-consistent instead of relying on every reader to apply the same default. Covered by testCachedEnabledWithUnparseableLevelFallsBackToError, which also asserts the inverse — is_enabled: false must not manufacture a level.

Active crash upload continuing after shutdown begins. Partly fixed, and I want to be straight about the limit. Starting is now blocked: the uploader's enqueue closure checks isActive, which flips the moment beginShutdown() runs, so no upload begins after teardown starts and finish() won't kick off a queued one either. But an upload already in flight can't be interrupted — LogCrashUploader exposes no cancellation, and its suspend start() runs to completion inside KMP. Genuinely stopping it needs a cancellation API in the KMP module, which is submodule work. Happy to file that as a follow-up if you agree it's worth it; the current behavior is that an in-flight upload finishes and deletes the reports it sent, which is at least not lossy.

License header artifacts. Removed. Those were line-number gutter prefixes from a file read that leaked into the content when I wrote the file — good catch.

Lint. Root cause was unrelated to the review: this branch was one commit behind main, and that commit (#1719) replaced the inline swiftlint step with a stricter standalone job. Merged main in, then found the repo has exactly one error-severity violation and it was mine — my test class was 352 lines against type_body_length's 350 error threshold. Split the configuration cases into OSRemoteLoggingConfigurationTests, which is better organization regardless. swiftlint now exits 0 locally. The remaining violations in these files are file_length warnings, which the linter tolerates — there are 296 such warnings repo-wide.

On the NONE question — you're right that it deserves an explicit decision, and it's a product call rather than mine. Today NONE means "enabled, crash handler installed process-wide, nothing exported," which matches Android's hydrate path. Android is actually inconsistent with itself here: its cached path uses OtelIdResolver.resolveRemoteLoggingEnabled(), which excludes NONE and therefore does not install the handler. So there's no single Android behavior to copy. If the intent of NONE is "we are not observing this app," installing a process-wide NSUncaughtExceptionHandler is arguably the wrong default and it should behave like disabled. If the intent is "stop shipping logs but stay ready to enable," current behavior is right. I'd rather you or @sherwin decide than pick silently — say which and I'll make it match, and align Android as a follow-up.

Tests: 19 in UnitTests (12 controller + 7 configuration) and 67 in OneSignalOSCoreTests, all passing.

@abdulraqeeb33
abdulraqeeb33 requested a review from a team August 21, 2026 20:35
@onesignal-deploy

Copy link
Copy Markdown
Collaborator

One warning remains in OSRemoteLogger.swift: forceFlush() is now gated, but it is not fully serialized with shutdown. Shutdown can begin after isActive passes while the asynchronous KMP flush is still running, allowing telemetry.shutdown() to overlap it. Please make shutdown wait for an in-flight flush completion or otherwise track active KMP operations.

The cache normalization and license-header fixes look good. The active crash-upload cancellation and NONE behavior remain acknowledged follow-ups or product decisions.

Gating forceFlush on a flag was not enough. The flush is asynchronous, so
shutdown could begin after the check passed and run its drain over the same
telemetry while the flush was still crossing into KMP.

Flushes now claim a slot on the lifecycle, and shutdown waits for outstanding
slots before draining. `beginFlush` refuses new flushes once shutdown starts, so
the set the drain waits on can only shrink. The wait is bounded at five seconds,
matching the cap KMP already puts on its own drain, so a wedged flush cannot
stop teardown from completing. Blocking is safe there because it runs on the
teardown queue while the KMP completion that releases the slot resumes on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Good catch — the flag check was insufficient for exactly the reason you describe. Fixed in e900732 by tracking the operation rather than testing a flag.

forceFlush now claims a slot on the lifecycle (beginFlush) and releases it in the KMP completion (endFlush). shutdown() waits for outstanding slots before running its drain. Since beginFlush refuses new flushes once beginShutdown() has run, the set the drain waits on can only shrink, so there's no starvation path.

Two deliberate choices in there:

The wait is bounded at five seconds, matching the cap KMP already applies to its own drain in LogTelemetryRemoteImpl.shutdown. A wedged flush therefore delays teardown by no more than the drain itself already could, rather than wedging it permanently — which matters because teardown runs on app-id changes and at termination.

Blocking is safe where it happens: the wait runs on the teardown queue, while the KMP completion that releases the slot resumes on main. So the releasing thread is never the blocked one.

Covered by two new lifecycle tests — testLifecycleShutdownWaitsForInFlightFlush asserts the drain blocks while a slot is held and proceeds once released, and testLifecycleRefusesNewFlushOnceShutdownBegins asserts the shrinking-set property. 69 tests in OneSignalOSCoreTests, all passing, and swiftlint exits 0.


Separately, on the red Build and Test: I believe that's an unrelated flake rather than anything on this branch. The failure was SubscriptionUpdateRaceTests.testPendingPrePermissionUpdateSendsLiveSubscribedPayloadAfterGrant, which is user/subscription code with no path to the logging changes here. It passes locally (all four cases in that suite), the same job passed on this branch's previous run, and that specific file was modified by #1719 as part of "fix flakey tests" — which this branch only picked up when I merged main in to get the new lint job.

I tried re-running the failed job to confirm, but it errored in 19s with Artifact not found for name: OneSignalKMP-XCFramework — the artifacts from that three-day-old run had expired, so the rerun was inconclusive rather than informative. The push above has started a fresh run, which should settle it. If it fails again on the same test I'll dig in properly rather than assume.

@onesignal-deploy

Copy link
Copy Markdown
Collaborator

One timeout-path issue remains: after waitForFlushesToDrain reaches five seconds, telemetry.shutdown() proceeds even when a flush is still active. KMP's explicit forceFlush() has no timeout and can remain blocked on HTTP, so the unsafe overlap is still possible in this case.

Please consider timing out or cancelling forceFlush inside KMP, or skipping shutdown when the Swift wait expires. The normal race is fixed, the tests look appropriate, and CI is green.

Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift Outdated
Comment thread iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingConfiguration.swift Outdated
Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift Outdated
AR Abdul Azeez and others added 2 commits August 24, 2026 15:59
OSRemoteLoggingConfig mirrored two fields of OSRemoteLoggingConfiguration and
existed only to be constructed at the one call site that invokes the evaluator.
The near-identical name made the pair easy to confuse for no benefit.

Android keeps OtelConfig separate because its config lives in a persistence
-backed Model that cannot be compared directly. OSRemoteLoggingConfiguration is
already an Equatable value type, so the evaluator now diffs it as-is.

Co-authored-by: Cursor <cursoragent@cursor.com>
The KMP module sets objcExportSuspendFunctionLaunchThreadRestriction=none, so
exported suspend functions can be called from any thread. The hop these calls
went through was unnecessary; the exception that prompted it almost certainly
came from an XCFramework built before that flag landed.

Removing it also closes the check-then-cross window in log(), since the
lifecycle guard and the KMP call are now sequential on one thread, and drops a
main-queue dispatch per exported record — which added up at verbose levels.

A new test calls an exported suspend function from a background queue and
asserts it completes, so this fails loudly if the flag is ever dropped.

forceFlush keeps its slot tracking: the flush completes asynchronously even
when started inline, so shutdown's drain could still overlap it.

Also drops canStartUploader, which had become a second name for isActive with a
single reader.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from nan-li August 24, 2026 21:11
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift
@abdulraqeeb33
abdulraqeeb33 merged commit 6ac2c86 into main Aug 25, 2026
4 checks passed
@abdulraqeeb33
abdulraqeeb33 deleted the ar/sdk-5047 branch August 25, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants