Skip to content
Open
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
3 changes: 3 additions & 0 deletions .changeset/v4-capture-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [
- **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs.
- **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options. PostHog drops an event whose option value it can't read.
- **Breaking:** merge event properties as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both 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` maps passed in the same call's properties, instead of being replaced by them.
- **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit.
- Double the wait after a failed send from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing it by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered.
- Cap analytics batches at 5 MiB per request; an event larger than that is sent on its own.
- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""` from any method, such as `screen`, `alias` or `group`.
- Ignore `identify` with a whitespace-only distinct ID, as for an empty one.

**Added**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,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
24 changes: 17 additions & 7 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,19 +302,25 @@ class PostHogApi {
) -> PostHogUploadInfo {
var retryUuids: Set<String> = []
var dropped = 0
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)
Expand All @@ -331,10 +337,14 @@ class PostHogApi {
} 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))" } ?? ""
hedgeLog("Capture \(path)\(status): \(dropped) dropped, \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.")
}

captureV1Lock.withLock {
Expand Down
58 changes: 44 additions & 14 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ let maxRetryDelay = 30.0
private let personPropsLock = NSLock()
private let cachedPersonPropertiesLock = NSLock()
private let identifyLock = NSLock()
private let emptyEventNameLock = NSLock()
private var warnedEmptyEventName = false

private let lastScreenLock = NSLock()
private let replayDebugPropertiesLock = NSLock()
Expand Down Expand Up @@ -785,14 +787,6 @@ let maxRetryDelay = 30.0
props["$is_identified"] = isIdentified
}

// Person processing is sent as the `process_person_profile` option, only when it's off
// (see `personProcessingOptions`). A registered or caller `$process_person_profile` wins
// over it when the send hoists the property. The crash-context snapshot stores only
// properties, so it keeps the property, which the send hoists.
if readOnlySession, !hasPersonProcessing() {
props["$process_person_profile"] = false
}

if config.disableGeoIp {
props["$geoip_disable"] = true
}
Expand Down Expand Up @@ -859,7 +853,13 @@ let maxRetryDelay = 30.0
}
// A caller-supplied $groups adds to the registered and per-call groups instead of replacing them.
if let callerGroups = callerProps["$groups"] as? [String: String], let currentGroups = props["$groups"] as? [String: String] {
callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new }
callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new }.merging(groups ?? [:]) { _, new in new }
}
// A typed per-call value wins key by key over the same map in the caller's properties.
for (key, typed) in [("$set", userProperties), ("$set_once", userPropertiesSetOnce)] where appendSharedProps {
if let typed, let callerMap = callerProps[key] as? [String: Any] {
callerProps[key] = callerMap.merging(typed) { _, new in new }
}
}
props.merge(callerProps) { _, new in new }

Expand Down Expand Up @@ -1063,8 +1063,8 @@ let maxRetryDelay = 30.0
return
}

if distinctId.isEmpty {
hedgeLog("identify call not allowed, distinctId is invalid: \(distinctId)")
if Self.isBlank(distinctId) {
hedgeLog("identify call not allowed, distinctId is empty or whitespace-only.")
return
}

Expand Down Expand Up @@ -1562,7 +1562,7 @@ let maxRetryDelay = 30.0
/// `capture`'s `options`.
///
/// - Returns: The event's UUID, or `nil` if the event wasn't queued: for example, the SDK is
/// opted out or disabled, `beforeSend` dropped it, or it's over `captureAiMaxEventBytes`.
/// opted out or disabled, its name is empty, `beforeSend` dropped it, or it's over `captureAiMaxEventBytes`.
@discardableResult
@objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)
public func captureAi(_ event: String,
Expand Down Expand Up @@ -1858,7 +1858,8 @@ let maxRetryDelay = 30.0
finalProperties[PostHogExceptionStepFields.stepsKey] = steps
}

// Snapshots and pre-built crash events don't carry the SDK's person-processing option.
// Snapshots don't carry the SDK's person-processing option; pre-built crash events carry the
// one saved with their crash context.
let eventOptions = skipBuildProperties || isSnapshotEvent
? sanitizeDictionary(options)
: personProcessingOptions(sanitizeDictionary(options), properties: finalProperties)
Expand All @@ -1876,6 +1877,14 @@ let maxRetryDelay = 30.0
return nil
}

// PostHog drops an event with an empty name, so it's dropped here instead. Checked after
// beforeSend, which can rename the event.
if posthogEvent.event.isEmpty {
warnEmptyEventNameOnce()
releaseClaimIfCarried(posthogEvent)
return nil
}

// Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook.
// The AI lane always uses its own queue.
isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot"
Expand Down Expand Up @@ -2203,6 +2212,16 @@ let maxRetryDelay = 30.0
queueEvent(event, queue: queue)
}

private func warnEmptyEventNameOnce() {
let shouldWarn = emptyEventNameLock.withLock {
defer { warnedEmptyEventName = true }
return !warnedEmptyEventName
}
if shouldWarn {
hedgeLog("Dropping an event with an empty name. Further empty names are dropped without a log.")
}
}

private func releaseClaimIfCarried(_ event: PostHogEvent) {
if event.carriesReplayDebugBundle {
releaseReplayDebugPropertiesClaim()
Expand Down Expand Up @@ -2252,6 +2271,13 @@ let maxRetryDelay = 30.0
/// Returns whether the event was stored in `queue`.
@discardableResult
private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue<PostHogEvent>, deduplicatePersonProperties: Bool = false) -> Bool {
// PostHog drops an event with an empty name. Checked here so every path is covered after
// beforeSend, which can rename the event.
if event.event.isEmpty {
warnEmptyEventNameOnce()
releaseClaimIfCarried(event)
return false
}
let storedInQueue: Bool
let userProperties = event.properties["$set"] as? [String: Any]
let userPropertiesSetOnce = event.properties["$set_once"] as? [String: Any]
Expand Down Expand Up @@ -3845,10 +3871,14 @@ let maxRetryDelay = 30.0

// Build crash context with identity info + event properties
// This structure allows crash reporting to reconstruct events with crash-time data
let context: [String: Any] = [
var context: [String: Any] = [
"distinct_id": distinctId,
"event_properties": eventProperties,
]
// Person processing is an option, not a property (see `personProcessingOptions`).
if let options = personProcessingOptions(nil, properties: eventProperties) {
context["options"] = options
}

onEventContextChanged.invoke(context)
}
Expand Down
Loading
Loading