A drop-in replacement for Apple's os.Logger that sends log messages to PostHog session replay as console log entries.
Apple's os.Logger writes to the unified logging system via a kernel-level ring buffer. It never touches stdout or stderr. PostHog's iOS SDK captures console logs for session replay by redirecting stdout/stderr file descriptors via dup2, which means os.Logger output is invisible to PostHog.
There is no public iOS API to intercept os.Logger output at runtime (Apple Developer Forums, Swift Forums). This is a known limitation - the PostHog team acknowledged that "we cannot swizzle all the logging options in ObjC/Swift."
PostHogLog solves this by providing a PostHogLogger struct with an identical API surface to os.Logger, sending log entries directly to PostHog using the same rrweb console plugin format the SDK uses internally.
- File > Add Package Dependencies
- Add
https://github.com/nicktosto/PostHogLog.git - Select "Up to Next Major" with "1.0.0"
Important — name collision with PostHog 3.58.0+. PostHog's iOS SDK ships its own
PostHogLoggeras of 3.58.0 (PRs #590 + #592) — a@objc final classfacade for the new PostHog Logs (OTLP) product, accessed viaPostHogSDK.shared.logger. Importing bothPostHogandPostHogLogin the same file makes the unqualified namePostHogLoggerambiguous. The examples below qualify it asPostHogLog.PostHogLoggerto disambiguate. The two types are unrelated: this package writes session-replay console entries shaped likeos.Logger; PostHog's class writes OTLP records to the Logs product. See Not for PostHog Logs (OTLP) below.
Use PostHogLog.PostHogLogger directly alongside os.Logger. This is the simplest approach if you want to keep os.Logger for on-device diagnostics and add PostHog logging separately.
import os
import PostHog
import PostHogLog
// One-time conformance - PostHogSDK already has the required capture method
extension PostHogSDK: @retroactive PostHogLogCapturing {}// os.Logger for on-device diagnostics (Console.app, Xcode)
extension Logger {
static let networking = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "networking")
}
// PostHogLog.PostHogLogger for session replay logs (PostHog dashboard).
// Qualified as `PostHogLog.PostHogLogger` because PostHog 3.58.0+ ships
// a class with the same short name.
extension PostHogLog.PostHogLogger {
private static let subsystem = Bundle.main.bundleIdentifier!
private static func configured(subsystem: String, category: String) -> Self {
PostHogLog.PostHogLogger(subsystem: subsystem, category: category)
.configure(
PostHogLoggerConfig(
sdk: PostHogSDK.shared,
logPrivacy: .public,
minLogLevel: .default
)
)
}
static let networking = configured(subsystem: subsystem, category: "networking")
}
Logger.networking.info("Request completed with status \(statusCode)")
PostHogLog.PostHogLogger.networking.info("Request completed with status \(statusCode)")
PostHogLog.PostHogLogger.networking.info("Authenticated as \(token, privacy: .private)")Use a typealias to switch between os.Logger in DEBUG and PostHogLogger in RELEASE. This gives you the best of both worlds: Apple's compiler-optimised logging with jump-to-source in Xcode during development, and PostHog session replay logs in production. One set of log call sites, no duplication.
Create a single file (e.g. AppLogger.swift) and copy the following:
import Foundation
import PostHog
import PostHogLog
// One-time conformance - PostHogSDK already has the required capture method
extension PostHogSDK: @retroactive PostHogLogCapturing {}
#if DEBUG
import os
typealias AppLogger = Logger
// No-op so configure() compiles in DEBUG without side effects.
// os.Logger ignores this entirely.
extension Logger {
func configure(_ config: PostHogLoggerConfig) -> Self { self }
}
#else
// Qualified as `PostHogLog.PostHogLogger` because PostHog 3.58.0+
// also exports a `PostHogLogger` (a class facade for the OTLP Logs
// product). Without the module prefix the name is ambiguous and
// the Release build fails with "'PostHogLogger' is ambiguous for
// type lookup in this context".
typealias AppLogger = PostHogLog.PostHogLogger
#endif
extension AppLogger {
private static let subsystem = Bundle.main.bundleIdentifier!
/// Creates a configured logger. In DEBUG this is a real os.Logger
/// (configure is a no-op). In RELEASE this sends to PostHog.
private static func configured(subsystem: String, category: String) -> Self {
AppLogger(subsystem: subsystem, category: category)
.configure(
PostHogLoggerConfig(
sdk: PostHogSDK.shared,
logPrivacy: .public,
minLogLevel: .default
)
)
}
}extension AppLogger {
static let networking = configured(subsystem: subsystem, category: "networking")
static let auth = configured(subsystem: subsystem, category: "auth")
}
AppLogger.networking.info("Request completed with status \(statusCode)")
AppLogger.auth.error("Token refresh failed: \(error)")
AppLogger.auth.info("Authenticated as \(token, privacy: .private)")PostHogLoggerConfig controls how PostHogLogger behaves:
PostHogLoggerConfig(
sdk: PostHogSDK.shared, // required - the PostHog SDK instance
logPrivacy: .public, // optional - defaults to .auto
minLogLevel: .default // optional - defaults to .error
)The minimum log level to send to PostHog. Messages below this level are silently dropped. Defaults to .error to match the default on PostHog's PostHogSessionReplayConsoleLogConfig.minLogLevel.
Note: PostHog's SDK config is not publicly exposed, so there is no way to read
PostHogSessionReplayConsoleLogConfig.minLogLevelprogrammatically. You must keep these two values in sync manually. If you change one, change the other.
Controls how .auto (the default privacy on all interpolated values) is interpreted. You likely want .public - the whole point of sending logs to PostHog is to see what's happening in your users' sessions. Redacted values like <private> in your session replay timeline aren't useful for debugging. If a value is truly sensitive (tokens, passwords), mark it .private explicitly at the call site.
logPrivacy |
.auto scalars (Int, Float, Bool) |
.auto strings & objects |
|---|---|---|
.auto (default) |
Visible | Redacted |
.public |
Visible | Visible |
Explicit .private and .sensitive annotations at the call site always redact, regardless of this setting.
PostHogLogger respects privacy annotations, matching Apple's behaviour when no debugger is attached (which is the closest analogy to "data sent to a third-party service"):
| Privacy | Scalars (Int, Float, Bool) | Strings & Objects |
|---|---|---|
.auto (default) |
Visible | Redacted (configurable via logPrivacy) |
.public |
Visible | Visible |
.private |
Redacted | Redacted |
.sensitive |
Redacted | Redacted |
Redacted values display as <private>, matching Apple's format. Use .private(mask: .hash) or .sensitive(mask: .hash) for deterministic hash-based redaction (<mask.hash: 'base64=='>), enabling log correlation without exposing values.
Apple's unified logging redacts at read time, not based on build configuration. Since PostHog is always a "no debugger" context, PostHogLogger always redacts.
Sources:
- WWDC 2016 "Unified Logging and Activity Tracing"
- OSLogPrivacy documentation
- OSLogInterpolation documentation - "the system doesn't redact integer, floating-point and Boolean values, but it does redact the contents of dynamic strings and complex dynamic objects."
- Redaction format
<mask.hash: '...'>: Unified logging Part 2 - Carrione
PostHogLogger maps os.Logger methods to PostHog session replay console log levels:
| Logger / PostHogLogger method | OSLogType | PostHog level |
|---|---|---|
trace(_:), debug(_:) |
.debug |
info |
info(_:) |
.info |
info |
log(_:), notice(_:) |
.default |
warn |
warning(_:), error(_:) |
.error |
error |
critical(_:), fault(_:) |
.fault |
error |
Note:
warning()maps to PostHog'serrorlevel, notwarn. This matches Apple'sos.Logger, wherewarning()usesOSLogType.errorinternally. PostHog only has three levels (info,warn,error), and since Apple treats warnings as errors, we preserve that mapping.
format: and align: parameters are accepted on all interpolation overloads for API compatibility, but do not affect the rendered output. Values are always rendered using their default String representation.
This means call sites like:
AppLogger.networking.info("Port: \(port, format: .hex)")
AppLogger.networking.info("ID: \(id, align: .left(columns: 20))")If using the Method 2 typealias approach, these compile in both configurations, but the hex/alignment formatting only takes effect in DEBUG (real os.Logger). In RELEASE, values render as plain decimal/unaligned strings.
Supported formatting types (all no-op in PostHogLogger):
PostHogLogIntegerFormatting-.decimal,.hex,.octalPostHogLogFloatFormatting-.fixed,.hex,.exponential,.hybridPostHogLogBoolFormat-.truth,.answerPostHogLogStringAlignment-.none,.left(columns:),.right(columns:)PostHogLogInt32ExtendedFormat-.ipv4Address,.darwinErrno, etc.PostHogLogIntExtendedFormat-.bitrate,.byteCount, etc.PostHogLogPointerFormat-.none,.uuid,.ipv6Address, etc.
init(_ logObj: OSLog)- Not implemented. This initialiser takes anos.Logger-specificOSLogobject. Useinit(subsystem:category:)orinit()instead.- Formatting is cosmetic-only -
format:andalign:parameters are accepted but ignored. The rendered output always uses the default string representation. subsystemis not logged - Thesubsystemparameter is accepted ininit(subsystem:category:)for API parity withos.Logger, but is not included in the log output sent to PostHog. Thecategoryis prepended to each message as[category]. The rrweb console plugin format has no field for subsystem, and since most apps use a single subsystem (the bundle identifier), including it would add noise to every log line. Open to feedback on how subsystem could be surfaced - open an issue if you have ideas.
PostHogLogger sends log entries as PostHog $snapshot events using the rrweb console plugin format (type: 6, plugin: "rrweb/console@1"). This is the same format that PostHog's built-in PostHogSessionReplayConsoleLogsPlugin uses.
PostHog's capture() method automatically merges the current $session_id when session replay is active, so log entries appear in the correct session replay timeline without manual session management.
Source: PostHog/posthog-ios#258 - "the capture method will merge the current $session_id and will link to the recording."
This package sends logs to PostHog Session Replay as console log entries in the rrweb format. It is not for exporting structured logs to PostHog Logs, which uses the OpenTelemetry (OTLP) protocol.
As of PostHog iOS SDK 3.58.0 (PRs #590 + #592), the official SDK exposes its own PostHogLogger for the Logs product, available as PostHogSDK.shared.logger with a trace / debug / info / warn / error / fatal surface. That class shares only the short name with this package's PostHogLogger; the two are not interchangeable:
PostHogLog.PostHogLogger (this package) |
PostHog.PostHogLogger (3.58.0+) |
|
|---|---|---|
| Type | struct: Sendable, drop-in for os.Logger |
@objc final class: NSObject, SDK facade |
| Construction | init(subsystem:category:) |
PostHogSDK.shared.logger (internal init) |
| API | os.Logger parity (log/notice/warning/critical/fault + privacy interpolation) |
OTLP severities, String body + [String: Any]? attributes |
| Destination | $snapshot console entry → Session Replay |
/i/v1/logs → PostHog Logs |
| Privacy | Per-segment .auto/.public/.private |
None at this layer (use beforeSend) |
If you import both PostHog and PostHogLog, qualify the name as PostHogLog.PostHogLogger to avoid the lookup ambiguity (see the warning at the top of "Usage").
If you want OTLP/structured logs from Swift to a non-PostHog backend (or in addition to this package's session-replay logs), check out:
- opentelemetry-swift - Official OpenTelemetry SDK for Swift
- swift-otel - Swift OpenTelemetry client