Skip to content

ptrkstr/PostHogLog

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PostHogLog

A drop-in replacement for Apple's os.Logger that sends log messages to PostHog session replay as console log entries.

Why?

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.

Installation

Swift Package Manager

  1. File > Add Package Dependencies
  2. Add https://github.com/nicktosto/PostHogLog.git
  3. Select "Up to Next Major" with "1.0.0"

Usage

Important — name collision with PostHog 3.58.0+. PostHog's iOS SDK ships its own PostHogLogger as of 3.58.0 (PRs #590 + #592) — a @objc final class facade for the new PostHog Logs (OTLP) product, accessed via PostHogSDK.shared.logger. Importing both PostHog and PostHogLog in the same file makes the unqualified name PostHogLogger ambiguous. The examples below qualify it as PostHogLog.PostHogLogger to disambiguate. The two types are unrelated: this package writes session-replay console entries shaped like os.Logger; PostHog's class writes OTLP records to the Logs product. See Not for PostHog Logs (OTLP) below.

Method 1: Standalone

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.

Setup

import os
import PostHog
import PostHogLog

// One-time conformance - PostHogSDK already has the required capture method
extension PostHogSDK: @retroactive PostHogLogCapturing {}

Usage

// 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)")

Method 2: Typealias (recommended)

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.

Setup

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
                )
            )
    }
}

Usage

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)")

Configuration

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
)

minLogLevel

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.minLogLevel programmatically. You must keep these two values in sync manually. If you change one, change the other.

logPrivacy

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.

Privacy

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:

Log levels

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's error level, not warn. This matches Apple's os.Logger, where warning() uses OSLogType.error internally. PostHog only has three levels (info, warn, error), and since Apple treats warnings as errors, we preserve that mapping.

Formatting parameters

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, .octal
  • PostHogLogFloatFormatting - .fixed, .hex, .exponential, .hybrid
  • PostHogLogBoolFormat - .truth, .answer
  • PostHogLogStringAlignment - .none, .left(columns:), .right(columns:)
  • PostHogLogInt32ExtendedFormat - .ipv4Address, .darwinErrno, etc.
  • PostHogLogIntExtendedFormat - .bitrate, .byteCount, etc.
  • PostHogLogPointerFormat - .none, .uuid, .ipv6Address, etc.

Limitations

  • init(_ logObj: OSLog) - Not implemented. This initialiser takes an os.Logger-specific OSLog object. Use init(subsystem:category:) or init() instead.
  • Formatting is cosmetic-only - format: and align: parameters are accepted but ignored. The rendered output always uses the default string representation.
  • subsystem is not logged - The subsystem parameter is accepted in init(subsystem:category:) for API parity with os.Logger, but is not included in the log output sent to PostHog. The category is 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.

How it works

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."

Not for PostHog Logs (OTLP)

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:

About

PostHog iOS console logger for Session Replay

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages