Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
786a373
refactor: [SDK-5065] remove the otel observability path and OpenTelem…
Aug 24, 2026
1a39304
fix: [SDK-5065] attribute JaCoCo coverage for Robolectric-loaded classes
Aug 24, 2026
a94b1e2
fix: [SDK-5065] bound crash-record retention and restore lifecycle te…
Aug 24, 2026
9a42d06
fix: [SDK-5065] enforce crash-record caps on every path and correct u…
Aug 24, 2026
9524cfb
fix: [SDK-5065] stop size-capped crash records from destroying the ba…
Aug 24, 2026
d7b42d6
fix: [SDK-5065] retry observability components that failed to start
Aug 25, 2026
ab4ecea
fix: [SDK-5065] restore canonical stacktrace formatting on ANR records
Aug 25, 2026
6aba52e
fix: [SDK-5065] reclaim future-dated crash records and require record…
Aug 26, 2026
0d0b9f2
chore: [SDK-5065] bump KMP submodule to the shared crash-retention po…
Aug 26, 2026
825f67b
refactor: [SDK-5065] use the shared crash-retention policy on Android
Aug 26, 2026
ba75ecf
chore: trim comment verbosity in the logger observability files
Aug 26, 2026
bb62276
test: [SDK-5065] pin the keepName wiring on the crash write path
Aug 26, 2026
209132e
fix: [SDK-5065] reconcile logger lifecycle against actual component l…
Aug 27, 2026
dcf5c29
chore: [SDK-5065] bump KMP submodule to the unknown-mtime retention fix
Aug 31, 2026
b60c755
chore: [SDK-5065] drop the swiftVersion override the contract no long…
Aug 31, 2026
d3d881d
fix: [SDK-5065] stop an unreadable mtime from expiring a live crash r…
Aug 31, 2026
e46c56a
fix: [SDK-5065] honor the remote logging kill switch on cold start
Aug 31, 2026
8dd0643
fix: [SDK-5065] keep the working log sink when a level change fails
Aug 31, 2026
07fc49d
chore: [SDK-5065] bump KMP submodule to the crash-uploader kill switch
Aug 31, 2026
2bd8f3c
fix: [SDK-5065] treat a NONE log level as remote logging off
Aug 31, 2026
9958bff
fix: [SDK-5065] keep FileLogStore's interrupted writes reclaimable
Aug 31, 2026
d976aec
docs: [SDK-5065] correct how often the cap check misfires, and re-pin…
Aug 31, 2026
a81afa5
docs: [SDK-5065] restore the FileLogStore class KDoc
Aug 31, 2026
84430b6
docs: [SDK-5065] cap every observability doc block at four lines
Aug 31, 2026
3e08078
fix: [SDK-5065] pass the uploader-path reclaim an explicit protected set
Aug 31, 2026
ffbdd9d
chore: [SDK-5065] bump KMP submodule to the undefaulted retention API
Aug 31, 2026
fda92be
docs: [SDK-5065] drop the ticket reference from the reclaim comment
Aug 31, 2026
9dcbb47
fix: [SDK-5065] tear down the crash uploader's telemetry with the pass
Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,35 @@ The above statement will bring in the entire OneSignalSDK and is the desired sta
- `com.onesignal:location`: Include to bring in location-based functionality.


## OpenTelemetry Dependency Removal

As of the release that removes the `:otel` module, the SDK no longer depends on OpenTelemetry. The `com.onesignal:otel` artifact is no longer published, and with it goes the entire `io.opentelemetry` dependency tree (`opentelemetry-api`, `-sdk`, `-exporter-otlp`, `-semconv`, and `opentelemetry-disk-buffering`). SDK diagnostics are now handled by the multiplatform `logger` module bundled inside `com.onesignal:core`, which has no third-party telemetry dependencies.

No public, supported API changed. The removal does delete internal API surface in `com.onesignal.debug.internal.logging` — most visibly `Logging.setOtelTelemetry`. That method took a parameter type (`IOtelOpenTelemetryRemote`) that only existed inside the `com.onesignal:otel` artifact, so no application could have compiled against it without depending on that artifact directly. If you did, remove the reference and rebuild.

For most integrations no action is required, but note the following:

- **If you declared `com.onesignal:otel` directly**, remove it. The artifact is no longer published.
- **If you added ProGuard/R8 rules for OneSignal's OpenTelemetry usage**, you can remove them. Rules such as the following are no longer needed, because those classes are never on the classpath via OneSignal:

```pro
-dontwarn com.fasterxml.jackson.core.**
-dontwarn com.google.auto.value.**
-dontwarn io.opentelemetry.api.incubator.**
-dontwarn io.opentelemetry.api.internal.**
```

- **If your app uses OpenTelemetry itself**, you no longer need to reconcile its version with OneSignal's. Whatever version you depend on is now the only one in your build, which removes a class of R8 "Missing class" failures caused by version skew between the two.
- **If you were excluding OpenTelemetry from the OneSignal dependency**, that exclusion is now a no-op and can be deleted.

One upgrade-time note about crash reports still buffered on disk when the upgrade happens. Which ones survive depends on the format they were written in:

- Reports written by the OpenTelemetry path use its disk-buffering format, which the new implementation cannot read. These are deleted unread on a subsequent launch, so a crash captured shortly before the upgrade may never arrive.
- Reports written by the newer logger path are already in the format the upgraded SDK uses, and are uploaded normally.

Reports captured from the upgraded version onward are unaffected.


## Code Modularization

The OneSignal SDK has been updated to be more modular in nature. The SDK has been split into namespaces and functionality previously in the static `OneSignal` class has been moved to the appropriate namespace. Some namespaces are only available if you include the associated module in your build (for simplicity, including module `com.onesignal:OneSignal` will automatically bring in all modules). The namespaces, their containing modules, and how to access them in code are as follows:
Expand Down
4 changes: 0 additions & 4 deletions OneSignalSDK/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ buildscript {
ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10)
spotlessVersion = '6.25.0'
tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP
// OpenTelemetry versions
opentelemetryBomVersion = '1.55.0'
opentelemetrySemconvVersion = '1.37.0'
opentelemetryDiskBufferingVersion = '1.51.0-alpha'

sharedRepos = {
google()
Expand Down
13 changes: 13 additions & 0 deletions OneSignalSDK/coverage/jacoco.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ subprojects {
testCoverageEnabled = true
}
}

// Robolectric loads classes through its own instrumenting classloader, which
// strips the source-location metadata JaCoCo uses to attribute execution. Without
// this, every class exercised only by a @RobolectricTest reports 0% coverage even
// when it is thoroughly tested.
testOptions {
unitTests.all {
jacoco {
includeNoLocationClasses = true
excludes = ['jdk.internal.*']
}
}
}
}

def coverageExcludes = [
Expand Down
4 changes: 1 addition & 3 deletions OneSignalSDK/onesignal/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,7 @@ dependencies {
}
}

// Otel module dependency
implementation(project(':OneSignal:otel'))
// Shared KMP module (OpenTelemetry-free logger) — the eventual replacement for :otel.
// Shared KMP module — backs the SDK's observability pipeline (remote logging, crash, ANR).
implementation(project(':OneSignal:kmp'))
testImplementation(project(':OneSignal:testhelpers'))

Expand Down
6 changes: 3 additions & 3 deletions OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Override otel/logger modules' minSdk requirement (26) since we have runtime checks -->
<!-- These modules are only used on SDK 26+, so this is safe -->
<uses-sdk tools:overrideLibrary="com.onesignal.otel, com.onesignal.logger" />
<!-- Override the logger module's minSdk requirement (26) since we have runtime checks -->
<!-- That module is only used on SDK 26+, so this is safe -->
<uses-sdk tools:overrideLibrary="com.onesignal.logger" />

<!-- Required so the device can access the internet. -->
<uses-permission android:name="android.permission.INTERNET" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ object OneSignalDispatchers {
*
* Background:
* The lazy `by lazy` properties below construct `ThreadPoolExecutor` instances and wrap them
* in `asCoroutineDispatcher() + SupervisorJob() + CoroutineScope(...)`. Production OTel
* in `asCoroutineDispatcher() + SupervisorJob() + CoroutineScope(...)`. Production remote-logging
* shows that when the **first** caller of [launchOnIO] / [launchOnSerialIO] is on the main
* thread (Activity-lifecycle handler, `JobService.onStartJob`, etc.), the construction cost
* — which includes a `kotlinx.coroutines.BuildersKt.launch` that hits
Expand Down Expand Up @@ -323,7 +323,7 @@ object OneSignalDispatchers {
* thread parked in a non-cancellable JVM wait (e.g. a `CountDownLatch.await()` that never gets
* released because the test asserted/failed first); a plain coroutine cancellation cannot free
* such a thread, so it permanently starves the small pool. Later specs that use the real pool
* (e.g. HttpClientTests' `launchOnIO {…}.join()`, OperationRepo, OtelIdResolver) then see
* (e.g. HttpClientTests' `launchOnIO {…}.join()`, OperationRepo, LoggerIdResolver) then see
* `launchOnIO` rejected/cancelled and observe null results.
*
* This atomically swaps in a fresh [Pools] generation and tears the old one down —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,7 @@ internal class FCMParamsObject(

internal class RemoteLoggingParamsObject(
val logLevel: com.onesignal.debug.LogLevel? = null,
val isEnabled: Boolean = logLevel != null,
// NONE means "ship nothing", so it has to read as disabled here the way the cached-config
// path already reads it; otherwise HYDRATE starts components with no delivery path.
val isEnabled: Boolean = logLevel != null && logLevel != com.onesignal.debug.LogLevel.NONE,
)
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,8 @@ class RemoteLoggingConfigModel(

/**
* Whether remote logging is enabled.
* Set by backend config hydration — true when the server sends a valid log_level, false otherwise.
* Set by backend config hydration — true when the server sends a valid log_level other than
* NONE, false otherwise.
*/
var isEnabled: Boolean
get() = getBooleanProperty(::isEnabled.name) { false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ internal class ConfigModelStoreListener(
params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it }
params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it }

params.remoteLoggingParams.logLevel?.let { config.remoteLoggingParams.logLevel = it }
// Unlike the fields above, an absent log_level is a revocation rather than a
// decline to change: keeping the old level would outlive the kill switch.
config.remoteLoggingParams.logLevel = params.remoteLoggingParams.logLevel
config.remoteLoggingParams.isEnabled = params.remoteLoggingParams.isEnabled

// Re-source from live model: replace() is a full data.clear()+putAll(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.onesignal.core.internal.http

/** Central API base URL used by all SDK HTTP traffic, including Otel log export. */
/** Central API base URL used by all SDK HTTP traffic, including remote log export. */
object OneSignalService {
// const val ONESIGNAL_API_BASE_URL = "https://api.staging.onesignal.com/"
const val ONESIGNAL_API_BASE_URL = "https://api.onesignal.com/"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
package com.onesignal.debug.internal.crash

import com.onesignal.logger.CrashData
import java.util.concurrent.atomic.AtomicLong

/**
* Pure, Android-free decision core for the ANR watchdog.
*
* All timing/classification/deduplication state lives here so it can be exercised deterministically
* on the JVM (with an injected clock) without a `Handler`, `Looper`, or real background thread. The
* Android shell ([OtelAnrDetector]) owns the thread, the real sleep, and the reporting side effects,
* and delegates every per-iteration decision to [evaluate].
*
* Foreground and background blocks keep independent dedup timestamps: a stream of backgrounded
* warnings must never suppress a genuine foreground ANR (and vice versa).
* Pure decision core for the ANR watchdog: all timing, classification and dedup state. Foreground
* and background blocks keep independent dedup timestamps, so neither can suppress the other.
*/
internal class AnrCheckEvaluator(
private val anrThresholdMs: Long,
Expand All @@ -21,7 +15,7 @@ internal class AnrCheckEvaluator(
private val dedupWindowMs: Long,
private val now: () -> Long,
) {
// Monotonic timestamps (from `now`); see OtelAnrDetector for why the clock must be monotonic.
// Monotonic timestamps (from `now`); see AndroidLogAnrDetector for why the clock must be monotonic.
private val lastResponseTime = AtomicLong(now())
private val lastForegroundReportTime = AtomicLong(NEVER_REPORTED)
private val lastBackgroundReportTime = AtomicLong(NEVER_REPORTED)
Expand All @@ -37,9 +31,8 @@ internal class AnrCheckEvaluator(
}

/**
* Evaluates a single watchdog iteration given how long the watchdog's own sleep actually took and
* the current app state. Returns the action the shell should take; all dedup bookkeeping is done
* here so the shell only performs side effects.
* Evaluates one watchdog iteration. All dedup bookkeeping happens here, so the shell only
* performs side effects.
*/
fun evaluate(actualSleepMs: Long, inForeground: Boolean): AnrCheckResult {
val timeSinceLastResponse = now() - lastResponseTime.get()
Expand All @@ -56,9 +49,8 @@ internal class AnrCheckEvaluator(
)
) {
BlockClassification.FROZEN_PROCESS -> {
// The watchdog thread itself was descheduled (Doze / cached-process freeze). Anything
// measured for the main thread is a freeze artifact, so re-baseline and treat as
// responsive to avoid firing on the next iteration.
// The watchdog thread itself was descheduled, so anything measured for the main
// thread is a freeze artifact: re-baseline rather than fire on the next iteration.
lastResponseTime.set(now())
AnrCheckResult.FrozenProcess(actualSleepMs = actualSleepMs, expectedSleepMs = checkIntervalMs)
}
Expand All @@ -81,9 +73,8 @@ internal class AnrCheckEvaluator(
val nowMs = now()
val lastReport = lastReportHolder.get()

// Skip only if we actually reported this class of block recently. NEVER_REPORTED means we have
// not reported yet (or the main thread recovered), so we must not dedup — important shortly
// after boot when the monotonic clock is still small and `now - 0` would look "recent".
// NEVER_REPORTED must not dedup: shortly after boot the monotonic clock is still small and
// `now - 0` would look "recent", suppressing the very first block.
if (lastReport != NEVER_REPORTED && nowMs - lastReport <= dedupWindowMs) {
return AnrCheckResult.Deduped(durationMs = durationMs, sinceLastReportMs = nowMs - lastReport, inForeground = inForeground)
}
Expand Down Expand Up @@ -125,31 +116,17 @@ internal sealed interface AnrCheckResult {
data class BackgroundWarning(val durationMs: Long) : AnrCheckResult
}

/**
* How a watchdog check is interpreted. Kept separate from side effects so the decision is a pure,
* deterministically testable function of the measured timings and app state.
*/
/** How a watchdog check is interpreted; see [AnrCheckResult] for what each case means. */
internal enum class BlockClassification {
/** Main thread responded within the applicable threshold. */
RESPONSIVE,

/** The watchdog thread's own sleep overran — the process was frozen, not the main thread. */
FROZEN_PROCESS,

/** Foreground block beyond the ANR threshold: a real, user-visible ANR. */
FOREGROUND_ANR,

/** Background block beyond the background threshold: not an ANR, recorded as a warning. */
BACKGROUND_WARNING,
}

/**
* Pure decision for a single watchdog check.
*
* Frozen-process detection wins first: if the watchdog's own sleep overran [checkIntervalMs] by more
* than [frozenSlackMs], the whole process was descheduled and any measured block is an artifact.
* Otherwise the applicable threshold depends on app state — [anrThresholdMs] in the foreground (where
* Android raises real ANRs) and the higher [backgroundThresholdMs] in the background.
* Frozen-process detection wins first: a watchdog sleep overrunning [checkIntervalMs] by more than
* [frozenSlackMs] means the process was descheduled, so any measured block is an artifact.
*/
@Suppress("LongParameterList")
internal fun classifyBlock(
Expand All @@ -170,13 +147,72 @@ internal fun classifyBlock(
}
}

/**
* Compact fingerprint for a captured main-thread stack: the top frame plus the first OneSignal frame.
* Kept as a queryable summary so background blocks can be grouped/triaged without parsing the full
* stack. Pure (operates only on the array) so it is covered by plain JVM tests.
*/
/** Compact fingerprint for a main-thread stack: top frame plus the first OneSignal frame. */
internal fun buildBlockFingerprint(stackTrace: Array<StackTraceElement>): String {
val topFrame = stackTrace.firstOrNull()?.toString() ?: "unknown"
val oneSignalFrame = stackTrace.firstOrNull { it.className.startsWith("com.onesignal") }?.toString() ?: "none"
return "top=$topFrame|onesignal=$oneSignalFrame"
}

/** Exception type for a foreground, user-visible ANR. Dashboards key off this exact value. */
internal const val ANR_EXCEPTION_TYPE = "ApplicationNotRespondingException"

/** Exception type for a backgrounded main-thread block, which is never an ANR. */
internal const val BACKGROUND_BLOCK_EXCEPTION_TYPE = "BackgroundMainThreadBlockException"

/**
* Must stay byte-identical to [Throwable.stackTraceToString]. ANR records come from a live thread
* rather than a throwable, and consumers parsing `exception.stacktrace` would stop matching ANRs.
*/
internal fun formatJvmStacktrace(
exceptionType: String,
exceptionMessage: String,
stackTrace: Array<StackTraceElement>,
): String {
// Matches printStackTrace, which terminates every line with the platform separator.
val lineSeparator = System.lineSeparator()
return buildString {
append(exceptionType)
if (exceptionMessage.isNotEmpty()) {
append(": ").append(exceptionMessage)
}
append(lineSeparator)
for (frame in stackTrace) {
append("\tat ").append(frame).append(lineSeparator)
}
}
}

/** Builds the fatal ANR record for a foreground block of [unresponsiveDurationMs]. */
internal fun buildAnrCrashData(
threadName: String,
stackTrace: Array<StackTraceElement>,
unresponsiveDurationMs: Long,
): CrashData {
val message = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms"
return CrashData(
threadName = threadName,
exceptionType = ANR_EXCEPTION_TYPE,
exceptionMessage = message,
stacktrace = formatJvmStacktrace(ANR_EXCEPTION_TYPE, message, stackTrace),
)
}

/**
* Builds the non-fatal record for a backgrounded main-thread block, with a
* [buildBlockFingerprint] summary embedded in the message.
*/
internal fun buildBackgroundBlockCrashData(
threadName: String,
stackTrace: Array<StackTraceElement>,
unresponsiveDurationMs: Long,
): CrashData {
val message =
"Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}"
return CrashData(
threadName = threadName,
exceptionType = BACKGROUND_BLOCK_EXCEPTION_TYPE,
exceptionMessage = message,
stacktrace = formatJvmStacktrace(BACKGROUND_BLOCK_EXCEPTION_TYPE, message, stackTrace),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.onesignal.debug.internal.crash

import android.os.Build

/** The single SDK-version gate for observability: crash reporting, ANR detection, log shipping. */
internal object ObservabilitySdkSupport {
/** The shared logger module requires Android O (API 26) or above. */
const val MIN_SDK_VERSION = Build.VERSION_CODES.O // 26

/** Read-only in production; writable only so tests can flip the gate without Robolectric. */
var isSupported: Boolean = Build.VERSION.SDK_INT >= MIN_SDK_VERSION
internal set

/** Restores the runtime-detected value — call in test teardown. */
fun reset() {
isSupported = Build.VERSION.SDK_INT >= MIN_SDK_VERSION
}
}
Loading