feat: report host app build toolchain on iOS logs - #1728
Conversation
The logger shipped `swiftVersion` and `additionalVersionAttributes` as placeholders, so iOS logs carried no signal about the toolchain that built the host app. Read Xcode's `DT*` keys from the host's Info.plist instead of a compile-time check: the SDK ships as a prebuilt XCFramework, so `#if swift(...)` here would describe OneSignal's build machine and be identical for every customer. Emits xcode_version, xcode_build, build_compiler, build_platform_*, and build_sdk_* as ossdk.* resource attributes. Apple exposes no runtime API for the Swift language version, so swiftVersion is approximated from the Xcode version via a floor lookup; the exact xcode_version rides alongside so a stale row stays recoverable downstream. kotlinVersion stays nil — it describes a Kotlin host app, which iOS is not. Co-authored-by: Cursor <cursoragent@cursor.com>
swiftVersion was approximated from the host's Xcode version, which is redundant: Xcode determines the Swift compiler 1:1, so the derived value only re-encodes xcode_version less precisely. The one thing a distinct value could carry is the per-target language mode (SWIFT_VERSION, 5 vs 6), and that is not in the host Info.plist at all — so the approximation reported the wrong thing for exactly the case where it would have been useful. Unlike Kotlin metadata, a module-stable .swiftinterface (we build every target with BUILD_LIBRARY_FOR_DISTRIBUTION) is consumed fine by newer compilers, so the support question is "is their Xcode at least ours", which xcode_version answers. Add minimum_os_version from MinimumOSVersion instead. That is the field that gates raising our own deployment target: os.version says what a customer's users run, while the declared minimum says what their build is pinned to, and only the latter breaks when we bump. Co-authored-by: Cursor <cursoragent@cursor.com>
| /// exactly as `xcode_version`. The one thing a distinct value could carry is the | ||
| /// per-target language mode (`SWIFT_VERSION`, 5 vs 6), and that is not in the | ||
| /// host's `Info.plist` at all. | ||
| let swiftVersion: String? = nil |
There was a problem hiding this comment.
i will remove this swiftVersion in another pr
|
Lets see what nan says but maybe we can drop DTXcodeBuild? |
|
Could we fall back to for key in ["MinimumOSVersion", "LSMinimumSystemVersion"] {
guard let value = infoDictionary?[key] as? String, !value.isEmpty else {
continue
}
attributes["minimum_os_version"] = value
break
} |
|
|
This is ok, nothing in the extension or outcomes reference remote logging. Even if we did it would be okay because the same xcode builds everything in general |
I agree on trimming this down and recommend we just add 3 new fields as everything else is just noise based on looking at the full picture of labels we get below. For example,
Here's a snippet of the payload |
| /// Xcode stamps its own version, the compiler, and the SDK it built against into | ||
| /// the *host app's* `Info.plist`, alongside the deployment target the app | ||
| /// declares. Reading `Bundle.main` is deliberate: this SDK ships as a prebuilt | ||
| /// XCFramework, so a compile-time check here would describe OneSignal's build | ||
| /// machine and be identical for every customer. | ||
| /// | ||
| /// These are build-time facts and answer what the host *commits to* supporting. | ||
| /// The OS actually running is reported separately as `os.name` / `os.version` / | ||
| /// `os.build_id`, and the two diverge: an app can serve only iOS 18 users while | ||
| /// still declaring a much older `minimum_os_version`, which is what constrains | ||
| /// raising our own deployment target. |
There was a problem hiding this comment.
nit do we need 10 lines to describe this?
There was a problem hiding this comment.
Trimmed. Now four lines, keeping only the part that is not evident from the code (why this is read at runtime rather than with a compile-time check).
| static let osBuildName = "kern.osversion" | ||
| static let xcodeVersionInfoKey = "DTXcode" | ||
| static let xcodeVersionAttribute = "xcode_version" | ||
| static let macCatalystAttribute = "apple_platform" |
There was a problem hiding this comment.
the name of this variable is a bit confusing. Can we be worded or something?
There was a problem hiding this comment.
Renamed macCatalystAttribute -> applePlatformAttribute so the constant matches the attribute name it holds.
| ("DTPlatformBuild", "build_platform_build"), | ||
| ("DTSDKName", "build_sdk_name"), | ||
| ("DTSDKBuild", "build_sdk_build"), | ||
| ("MinimumOSVersion", "minimum_os_version") |
There was a problem hiding this comment.
yeah, agree with Fadi on including LSMinimumSystemVersion for catalyst
There was a problem hiding this comment.
Added the fallback, using Fadi's ordering — MinimumOSVersion first, then LSMinimumSystemVersion. Covered by two tests: the Catalyst-only fallback, and that MinimumOSVersion wins when both are present.
| /// raising our own deployment target. | ||
| private static let buildMetadataKeys: [(infoKey: String, attribute: String)] = [ | ||
| ("DTXcodeBuild", "xcode_build"), | ||
| ("DTCompiler", "build_compiler"), |
There was a problem hiding this comment.
recommend to drop this field as DTCompiler has been the constant com.apple.compilers.llvm.clang.1_0 on effectively every app built since about 2011
There was a problem hiding this comment.
Dropped build_compiler, and took the rest of the trim too — build_platform_name, build_platform_version, build_platform_build, build_sdk_name, build_sdk_build. Left with xcode_version, xcode_build, minimum_os_version, plus the pre-existing apple_platform.
Review found most of the emitted set was noise. DTCompiler has been the same constant on effectively every app since 2011; build_sdk_name is just build_platform_name and build_platform_version concatenated; build_platform_build matches build_sdk_build in practice; and build_platform_name duplicates what device.model.identifier already says. Drops all six, leaving xcode_version, xcode_build, and minimum_os_version alongside the pre-existing apple_platform. Fall back to LSMinimumSystemVersion when MinimumOSVersion is absent, so Catalyst hosts report a deployment target instead of omitting the field. Rename macCatalystAttribute to applePlatformAttribute so the constant matches the attribute it holds, and cut the doc comments back to the reasoning that is not evident from the code. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Pushed Kept (3 new + 1 pre-existing):
Dropped: The payload sample made the redundancy hard to argue with — Catalyst fallback (@fadi-george): added with your ordering, NSE / Also renamed
Tests: 107 passing, 0 failures, against the pinned KMP submodule. Net -47/+61 across the provider and its tests. |
Summary
OSLoggerPlatformProvidershippedswiftVersion,kotlinVersion, andadditionalVersionAttributesas placeholders (nil/ Catalyst-only), so iOS logs carried nothing about the toolchain that built the host app.LogFieldsusesputIfValueNotBlank, so those attributes were silently absent from every record rather than failing loudly.This reads Xcode's
DT*keys and the declared deployment target from the host app'sInfo.plistviaBundle.main.Why not a compile-time check.
#if swift(>=6.0)inside SDK source is evaluated when we build the SDK. Since we distribute a prebuilt XCFramework (podspecs vendOneSignalCore.xcframework,Package.swiftuses binary targets), that value freezes at our build time and is identical for every customer — it would duplicateossdk.sdk_base_versionand tell you nothing about the app. ReadingBundle.maindescribes the customer's build and varies per app.Attributes added
Emitted as
ossdk.<key>resource attributes (attached once per resource, not per record):xcode_versionDTXcode, decoded26.2xcode_buildDTXcodeBuild17C52build_compilerDTCompilercom.apple.compilers.llvm.clang.1_0build_platform_nameDTPlatformNameiphonesimulatorbuild_platform_versionDTPlatformVersion26.2build_platform_buildDTPlatformBuild23C53build_sdk_nameDTSDKNameiphonesimulator26.2build_sdk_buildDTSDKBuild23C53minimum_os_versionMinimumOSVersion12.0Prefixed
build_sdk_*rather thansdk_*because "SDK" already means the OneSignal SDK in this namespace —ossdk.sdk_namewould read as ours, not Apple's.DTXcodeis packed ("2620"= 26.2) so it is decoded rather than shipped raw. Blank and missing keys are dropped rather than emitted empty.Why
minimum_os_versionmattersThese attributes exist to answer support questions, not for general telemetry, and this is the one that answers "can we raise our deployment target?"
It is deliberately distinct from
os.version, which is already emitted.os.versionis what a customer's users are running;minimum_os_versionis what their build is pinned to. A customer can serve 100% iOS 18 traffic while still declaring min iOS 13 — and raising our deployment target breaks their build regardless of where their users are. #1724 just raised our minimum to iOS 12; this is the field that would show who that affects before the next bump.Why
swiftVersionstays nilAn earlier revision of this PR approximated
swiftVersionfrom the Xcode version via a lookup table. That has been dropped, and the field is leftnil. Three reasons:1. It is redundant with
xcode_version. Xcode determines the Swift compiler 1:1 — there is no Xcode 16 shipping a Swift 5.9 compiler. Anything derived from the Xcode version is a lossy re-encoding of a field we already emit exactly, and can only ever be less accurate than the value sitting next to it.2. The one thing a distinct value could carry, this approach cannot capture. A
swift_versionmeaningfully different from the Xcode version would be the language mode — the per-targetSWIFT_VERSIONbuild setting, 5 vs 6. That would answer a real question ("if we adopt Swift 6 strict concurrency in our public API, do customers still building in Swift 5 mode break?"). But language mode is not in the hostInfo.plistat all, and an approximation derived from the compiler reports the wrong thing for exactly that case. A field that looks like it answers the question and does not is worse than an absent field.3. The iOS compatibility cliff is not shaped like Kotlin's. On Android,
kotlin_versiongates a real hazard: Kotlin metadata is forward-incompatible, so a library compiled with Kotlin 2.x cannot be consumed by a 1.9 compiler, making the customer's Kotlin version a hard blocker on bumping ours. iOS has no equivalent, because we build every target withBUILD_LIBRARY_FOR_DISTRIBUTION = YESand therefore ship a module-stable.swiftinterface, which newer compilers consume fine. The practical gate is "customer's Xcode >= ours", andxcode_versionanswers that directly.Worth noting why Android can do this and we cannot:
KotlinVersion.CURRENTworks becausekotlin-stdlibis an ordinary dependency bundled into the APK, so the version travels with the app as readable data. Swift's runtime has been ABI-stable and OS-provided since Swift 5, so there is no app-bundled artifact carrying a language version — anything readable at runtime describes the OS's Swift runtime, which already tracksos.version.kotlinVersionlikewise staysnil: the protocol scopes it to a Kotlin host app, which iOS is not. The shared module's own provenance is already onossdk.kmp_version.Test plan
OneSignalOSCoreTestsgreen againstmainwith KMP at the pinnedv0.3.0OSLoggerHostBuildAttributesTestscoveringDTXcodedigit unpacking, key mapping includingminimum_os_version, and omission of blank/missing keysswiftlintclean on the changed source fileossdk.*attributes land in GCP Logs Explorer on a real device buildVerify on device with:
Related: SDK-5081 (bug bash covering remote logging + feature flags).