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: 4 additions & 1 deletion .changeset/v4-major-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ PostHog iOS 4.0: see the [migration guide](https://github.com/PostHog/posthog-io
- **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs
- **Breaking:** send `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` as capture options, so PostHog drops an event whose option value it can't read
- **Breaking:** let registered super properties override SDK properties such as `$os`, and let super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values
- **Breaking:** let `userProperties`, `userPropertiesSetOnce` and `groups` win key by key over `$set`, `$set_once` and `$groups` passed in the same call's properties
- **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`
- Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, also accepted by `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`
- Add `captureAi(...)` to send AI events such as `$ai_generation` to `/i/v1/ai/events`, tuned with `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes`
- Change the wait after a failed send to double from 1 second up to 30 seconds with jitter, for events, session replay and logs
- Cap analytics batches at 5 MiB per request and send a larger event on its own
- Log the name, UUID and reason of each event PostHog drops when `debug` is on
- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""`
- Ignore `identify` with a whitespace-only distinct ID
- Print a warning, also without `debug`, when `setup` leaves the SDK disabled because `projectToken` is empty or a personal API key
- Add `SessionReplay`, `CrashReporting` and `Surveys` Swift package traits (on by default) that remove the feature and its public API when turned off
- Change network reachability detection to `NWPathMonitor`, so a path needing user action counts as reachable, queues pause while cellular data is off for the app, and early events may omit `$network_wifi` and `$network_cellular`
- Change the replay screen name to the visible screen, reporting the view name for SwiftUI screens and no longer using a plain `UIViewController`'s `title`
Expand Down
10 changes: 7 additions & 3 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Capture v1 requests send the project token in an `Authorization: Bearer <project
This header replaces any `Authorization` header you set in `PostHogConfig.requestHeaders`, so authenticate your proxy with a different header, such as `X-Proxy-Token`.
Other requests still send your `Authorization` header.

A personal API key (`phx_...`) in `projectToken` now disables the SDK at setup, as an empty token does, and logs why when `debug` is on.
A personal API key (`phx_...`) in `projectToken` now disables the SDK at setup, as an empty token does, and prints a warning explaining why, also without `debug`.
In 3.x, the SDK started and PostHog rejected every event with a 401. Use your project token (`phc_...`).

#### Hosts without capture v1
Expand Down Expand Up @@ -173,10 +173,13 @@ If a proxy redirects capture requests to another host, point `host` at the final
### Event validation

Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered.

The SDK drops an event with an empty event name before sending it, for example `capture("")` or a name `beforeSend` sets to `""`.
`captureAi` then returns `nil`, and with `debug` on the SDK logs the first one.

The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue.
With `debug` on, the SDK logs how many events were dropped.
With `debug` on, the SDK logs how many events were dropped and why.

- An event with an empty event name is dropped, for example `capture("")`.
- An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`.
An empty or whitespace-only `distinctId` argument isn't dropped: like `nil`, it uses the current distinct ID.
- An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length.
Expand Down Expand Up @@ -207,6 +210,7 @@ An event's properties are merged in this order, and later values win:

Per-call `userProperties` and `userPropertiesSetOnce` are applied between steps 2 and 3, so they win over a registered `$set` or `$set_once`, as in 3.x.
Groups set with `group()` and per-call `groups` are applied there too, and replace a registered `$groups`.
If you also pass `$set`, `$set_once` or `$groups` in the properties, the per-call `userProperties`, `userPropertiesSetOnce` and `groups` win key by key over them. In 3.x, `$set` and `$set_once` in the properties replaced the per-call values, and `$groups` in the properties won per key.

In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug properties also won over the properties you pass.
In 4.0 a value you register or pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on.
Expand Down

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a crash report has no saved context, the crash event is sent with only the exception properties, and skipBuildProperties also skips the person-processing option and $geoip_disable. So on an anonymous device with personProfiles: .identifiedOnly, the crash event processes the person, and with disableGeoIp on, GeoIP still runs. Could this path add the current person-processing option and $geoip_disable when the saved context is missing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17e283e — with no saved context, the event now gets the current person-processing option and $geoip_disable.

Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ import Foundation
crashSteps = decoded[PostHogExceptionStepFields.stepsKey] as? [[String: Any]] ?? []
}

// Steps alone don't count as a saved context: customData always carries the steps key.
if savedContext["event_properties"] == nil {
savedContext.merge(postHog.crashContextFallback()) { _, fallback in fallback }
}

// Extract identity and event properties from saved context
let crashDistinctId = savedContext["distinct_id"] as? String
let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:]
Expand Down Expand Up @@ -286,6 +291,7 @@ import Foundation
distinctId: crashDistinctId,
properties: finalProperties,
timestamp: crashTimestamp,
options: savedContext["options"] as? [String: Any],
skipBuildProperties: true
)

Expand Down
3 changes: 3 additions & 0 deletions PostHog/ErrorTracking/PostHogMemoryExceptionReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ import Foundation
)

var distinctId: String?
var options: [String: Any]?
if let pid = report.environment.pid,
let saved = contextStore.takeContext(pid: pid, notAfter: report.timeRange.end)
{
distinctId = saved["distinct_id"] as? String
options = saved["options"] as? [String: Any]
let eventProperties = saved["event_properties"] as? [String: Any] ?? [:]
properties = eventProperties.merging(properties) { _, new in new }
if let steps = saved[PostHogExceptionStepFields.stepsKey] as? [[String: Any]], !steps.isEmpty {
Expand All @@ -76,6 +78,7 @@ import Foundation
distinctId: distinctId,
properties: properties,
timestamp: report.timeRange.end,
options: options,
skipBuildProperties: true
)
hedgeLog("Memory exception report processed")
Expand Down
43 changes: 25 additions & 18 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ class PostHogApi {
}

let uuids = events.map(\.uuid.postHogUuidString)
let eventNames = Dictionary(events.map { ($0.uuid.postHogUuidString, $0.event) }, uniquingKeysWith: { first, _ in first })
let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids))

let toSend: [String: Any] = [
Expand Down Expand Up @@ -250,7 +249,7 @@ class PostHogApi {
if info.statusCode == 404 {
self.warnCaptureNotFoundOnce(url: url, path: path)
}
completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames))
completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity))
}
}.resume()
}
Expand Down Expand Up @@ -291,51 +290,59 @@ class PostHogApi {
}

/// Reads the per-event results of a 2xx and records which events, if any,
/// the next request retries under the same identity. `eventNames` maps
/// UUIDs to event names for the per-event drop log.
/// the next request retries under the same identity.
func captureV1Result(
_ info: PostHogUploadInfo,
path: String,
data: Data?,
uuids: [String],
identity: CaptureV1RequestIdentity,
eventNames: [String: String] = [:]
identity: CaptureV1RequestIdentity
) -> PostHogUploadInfo {
var retryUuids: Set<String> = []
var dropped = 0
var droppedByDetails: [String: Int] = [:]
var duplicatesDropped = 0
let statusCode = info.statusCode

if let statusCode, 200 ... 299 ~= statusCode {
// A 2xx whose results can't be read, including a transfer that failed
// after the status arrived, counts as delivered: the server already
// stored its ok events, and a resend would store them again.
// after the status arrived, fails the whole batch. It isn't retried:
// the server may have stored its ok events, and a resend would store them again.
let results = data.flatMap { fromJSONData($0) }?["results"] as? [String: Any]
if results == nil {
hedgeLog("Capture returned \(statusCode) without readable per-event results, treating \(uuids.count) events as delivered.")
dropped = uuids.count
hedgeLog("Capture returned \(statusCode) without readable per-event results, dropping \(dropped) events.")
}
// Events missing from the results count as delivered.
for uuid in uuids {
// Events missing from the results count as delivered. A repeated UUID takes its
// first copy's result; capture counts the later copies it dropped in `duplicates_dropped`.
var seen: Set<String> = []
for uuid in uuids where seen.insert(uuid).inserted {
guard let entry = results?[uuid] as? [String: Any] else { continue }
// Clamped to the batch size so a malformed count can't overflow the sum.
duplicatesDropped += min(max((entry["duplicates_dropped"] as? Int) ?? 0, 0), uuids.count)
switch entry["result"] as? String {
case "retry":
retryUuids.insert(uuid)
case "drop":
dropped += 1
// Debug only. Name, UUID, and the server's reason; never content or properties.
let name = eventNames[uuid].map { "'\($0)'" } ?? "event"
let details = entry["details"] as? String ?? "no details"
hedgeLog("Capture dropped \(name) (\(uuid)): \(details).")
droppedByDetails[entry["details"] as? String ?? "no details", default: 0] += 1
default:
break
}
}
} else if statusCode.map(isCaptureV1RetriableStatusCode) ?? true {
// No response or a retriable status: the whole batch is resent.
retryUuids = Set(uuids)
} else if statusCode != 413 || uuids.count == 1 {
// A terminal status loses the whole batch. The queue halves and resends a multi-event 413.
dropped = uuids.count
}

if dropped > 0 || !retryUuids.isEmpty {
hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.")
if dropped > 0 || duplicatesDropped > 0 || !retryUuids.isEmpty {
let status = statusCode.map { " (status \($0))" } ?? ""
// One line per batch: counts and the server's reasons, never event names, UUIDs or properties.
let reasons = droppedByDetails.isEmpty ? "" : " (" + droppedByDetails.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + ")"
hedgeLog("Capture \(path)\(status): \(dropped) dropped\(reasons), \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.")
}

captureV1Lock.withLock {
Expand Down Expand Up @@ -377,7 +384,7 @@ class PostHogApi {
var options = event.options
for (property, option) in legacyOptionProperties {
guard let legacy = properties.removeValue(forKey: property) else { continue }
if options[option] == nil || options[option] is NSNull {
if isNullValue(options[option]) {
options[option] = legacy
}
}
Expand Down
Loading
Loading