Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/great-tips-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": patch
---

correct frame addresses, ordering, and in-app detection
2 changes: 1 addition & 1 deletion PostHog/ErrorTracking/Models/PostHogStackFrame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
/// Information about a single stack frame
struct PostHogStackFrame {
/// Format string for converting UInt64 addresses to hex strings (e.g., "0x7fff12345678")
static let hexAddressFormat = "0x%llx"
static let hexAddressFormat = "0x%016llx"

/// The instruction address where the frame was executing
let instructionAddress: UInt64
Expand Down
9 changes: 6 additions & 3 deletions PostHog/ErrorTracking/PostHogCrashReportProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ import Foundation
return nil
}

// Add stack trace from frames
// Add stack trace from frames.
// Frames are stored bottom-up (outermost/main first, crash site last) to match
// the Sentry event format. PLCrashReport delivers them top-down, so reverse.
if !stackFrames.isEmpty {
exception["stacktrace"] = [
"frames": stackFrames.map(\.toDictionary),
"frames": stackFrames.reversed().map(\.toDictionary),
"type": "raw",
]
}
Expand Down Expand Up @@ -189,7 +191,8 @@ import Foundation
let inApp = module.map { PostHogStackTraceProcessor.isInApp(module: $0, config: config) } ?? false

let stackFrame = PostHogStackFrame(
instructionAddress: frame.instructionPointer,
// Strip PAC bits so the address is a valid instruction pointer on arm64e.
instructionAddress: frame.instructionPointer.pacStripped,
module: module,
package: package,
imageAddress: imageAddress,
Expand Down
8 changes: 6 additions & 2 deletions PostHog/ErrorTracking/PostHogExceptionProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ enum PostHogExceptionProcessor {

guard !frames.isEmpty else { return nil }

let frameDicts = frames.compactMap(\.toDictionary)
// Reverse to bottom-up order (outermost/main first, innermost/crash site last)
// to match the Sentry event format. callStackReturnAddresses is top-down.
let frameDicts = frames.reversed().compactMap(\.toDictionary)

guard !frameDicts.isEmpty else { return nil }

Expand All @@ -407,7 +409,9 @@ enum PostHogExceptionProcessor {

guard !frames.isEmpty else { return nil }

let frameDicts = frames.compactMap(\.toDictionary)
// Reverse to bottom-up order (outermost/main first, innermost/throw site last)
// to match the Sentry event format. callStackReturnAddresses is top-down.
let frameDicts = frames.reversed().compactMap(\.toDictionary)

guard !frameDicts.isEmpty else { return nil }

Expand Down
16 changes: 16 additions & 0 deletions PostHog/ErrorTracking/Utils/PostHogErrorTrackingUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ extension String {
}
}

// MARK: - Address Helpers

extension UInt64 {
/// Strip Pointer Authentication Code (PAC) bits from an arm64e address.
///
/// On arm64e devices (all modern iPhones with Apple Silicon), the OS embeds
/// authentication codes in the high bits of pointers. Masking with
/// `0x0000000fffffffff` removes those bits so the address is a valid
/// instruction pointer that can be looked up in the symbol table.
///
/// Safe to apply on plain arm64 too — the high bits are always zero there.
var pacStripped: UInt64 {
self & ~(~UInt64(0) << 36)
}
}

// MARK: - CPU Architecture Helpers

enum PostHogCPUArchitecture {
Expand Down
79 changes: 64 additions & 15 deletions PostHog/ErrorTracking/Utils/PostHogStackTraceProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ enum PostHogStackTraceProcessor {
var shouldCollectFrame = !stripTopPostHogFrames

for addressNum in addresses {
let address = addressNum.uintValue
// Strip PAC bits so the address is a valid instruction pointer on arm64e.
let address = UInt64(addressNum.uintValue).pacStripped
var info = Dl_info()

guard dladdr(UnsafeRawPointer(bitPattern: UInt(address)), &info) != 0 else {
Expand All @@ -53,6 +54,8 @@ enum PostHogStackTraceProcessor {
var module: String?
var package: String?
var imageAddress: UInt64?
// Default to false: if we cannot determine which binary owns this
// address (dli_fname is nil), we have no evidence it is user code.
var inApp = false

// Binary image info
Expand All @@ -62,8 +65,11 @@ enum PostHogStackTraceProcessor {
module = moduleName
package = path
imageAddress = UInt64(UInt(bitPattern: info.dli_fbase))
inApp = isInApp(module: moduleName, config: config)
inApp = isInApp(module: moduleName, package: path, config: config)
}
// If dladdr succeeded but returned no path (shared-cache framework on
// simulator or private framework on device), the frame is not user code.
// inApp stays false — no further action needed.

// Skip PostHog frames at the top of the stack
if !shouldCollectFrame {
Expand All @@ -82,7 +88,7 @@ enum PostHogStackTraceProcessor {
}

let frame = PostHogStackFrame(
instructionAddress: UInt64(address),
instructionAddress: address,
module: module,
package: package,
imageAddress: imageAddress,
Expand Down Expand Up @@ -114,7 +120,7 @@ enum PostHogStackTraceProcessor {
/// - module: The module/binary name to check
/// - config: Error tracking configuration
/// - Returns: true if the frame should be marked as in-app
static func isInApp(module: String, config: PostHogErrorTrackingConfig) -> Bool {
static func isInApp(module: String, package: String? = nil, config: PostHogErrorTrackingConfig) -> Bool {
// Priority 1: Check includes (highest priority)
if config.inAppIncludes.contains(where: { module.hasPrefix($0) }) {
return true
Expand All @@ -125,11 +131,18 @@ enum PostHogStackTraceProcessor {
return false
}

// Priority 3: Check known system frameworks (hardcoded)
// Priority 3: Check known system frameworks (hardcoded name list)
if isSystemFramework(module) {
return false
}

// Priority 3b: Check system binary path (catches private frameworks not in the name list,
// e.g. "Gestures" from UIKitCore, "Accessibility", etc.)
// Handles both device paths (/System/...) and Simulator paths (...RuntimeRoot/System/...)
if isSystemPath(package) {
return false
}

// Priority 4: Use default (final fallback)
return config.inAppByDefault
}
Expand All @@ -140,36 +153,72 @@ enum PostHogStackTraceProcessor {
/// It may need to be updated based on real-world usage, or moved to cymbal
/// which can further categorize frames and override in-app frames based on module paths
private static let systemPrefixes = [
// Public Apple frameworks
"Foundation",
"UIKit",
"AppKit",
"CoreFoundation",
"libsystem_kernel.dylib",
"libsystem_pthread.dylib",
"libdispatch.dylib",
"CoreGraphics",
"QuartzCore",
"Security",
"SystemConfiguration",
"CFNetwork",
"CoreData",
"CoreLocation",
"CoreMotion",
"CoreServices",
"CoreText",
"AVFoundation",
"Metal",
"MetalKit",
"SwiftUI",
"SwiftUICore",
"Combine",
"AppKit",
"libswift",
"IOKit",
"QuartzCore",
"Security",
"SystemConfiguration",
"CFNetwork",
"WebKit",
"GraphicsServices",
"IOKit",
// Private UIKit sub-frameworks (dyld shared cache, dli_fname may be nil
// or contain no recognisable path prefix on some OS versions)
"Gestures",
"UIKitCore",
"UIKitMacHelper",
"Accessibility",
"AccessibilityUtilities",
"UpdateCycle",
"TextInput",
"UIFoundation",
"BackBoardServices",
"FrontBoardServices",
"SpringBoardServices",
"RunningBoardServices",
// System dylibs
"libsystem_kernel.dylib",
"libsystem_pthread.dylib",
"libsystem_c.dylib",
"libsystem_malloc.dylib",
"libdispatch.dylib",
"libswift",
"libobjc",
"dyld",
]

/// Check if a module is a known system framework
private static func isSystemFramework(_ module: String) -> Bool {
systemPrefixes.contains { module.hasPrefix($0) }
}

/// Check if a binary path is a system library path.
/// Matches both device paths and Simulator runtime paths.
private static func isSystemPath(_ path: String?) -> Bool {
guard let path = path else { return false }
// Device: /System/Library/..., /usr/lib/...
// Simulator: .../RuntimeRoot/System/Library/..., .../RuntimeRoot/usr/lib/...
return path.hasPrefix("/System/") ||
path.hasPrefix("/usr/lib/") ||
path.contains("/System/Library/") ||
path.contains("/usr/lib/")
}

// MARK: - PostHog Frame Detection

/// Check if a module belongs to the PostHog SDK
Expand Down
2 changes: 1 addition & 1 deletion PostHogTests/PostHogStackTraceProcessorTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ struct PostHogStackTraceProcessorTest {

let dict = frame.toDictionary

#expect(dict["instruction_addr"] as? String == "0x1000000")
#expect(dict["instruction_addr"] as? String == "0x0000000001000000")
#expect(dict["platform"] as? String == "apple")
#expect(dict["in_app"] as? Bool == true)
#expect(dict["module"] as? String == "TestModule")
Expand Down
Loading