Skip to content

Replace fabricated user agents with structured device info; add watchOS#8

Open
srna wants to merge 10 commits into
Openpanel-dev:mainfrom
srna-lib:feature/structured-device-info
Open

Replace fabricated user agents with structured device info; add watchOS#8
srna wants to merge 10 commits into
Openpanel-dev:mainfrom
srna-lib:feature/structured-device-info

Conversation

@srna

@srna srna commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7 (bugfix/macos-fixes). Only the last 4 commits are new here; if that PR merges first this diff collapses to just them.

Replaces all user-agent fabrication with the structured device-info format the backend already understands. SDK-only — no backend changes needed. Every claim below was verified by running the actual backend parser (packages/common/server/parser-user-agent.ts) and bot detector against the new UAs.

What changes

1. One deterministic UA on every platform (ed1045e)

OpenPanel/1.0.1 (Model=iPhone15,2; Manufacturer=Apple)

The Model=…; Manufacturer=… form is the backend's sanctioned app-traffic format (APP_MODEL_REGEX): it sets device model/vendor and defeats the isServer classification. Verified parse results for iPhone15,2, iPad13,1, MacBookPro18,3, AppleTV14,1, and Watch6,9: correct OS/device/brand/model, isServer: false, and none match the bot list.

This deletes the WKWebView navigator.userAgent evaluation (which blocked the main thread on a semaphore for a guaranteed 1 s when initialized on the main thread — the async block couldn't run until the wait timed out), both hand-built fake Safari UA strings, and the WebKit import. Hardware model comes from sysctl hw.model on macOS, utsname.machine elsewhere, SIMULATOR_MODEL_IDENTIFIER under simulators.

2. Structured device properties on every event (8e44f3c)

initialize() seeds __os, __osVersion, __device, __brand, __model into global properties — the overrides the backend prefers to UA parsing. Values follow the ua-parser-js v2 vocabulary ("macOS" not "Mac OS"; mobile/tablet/desktop/smarttv/wearable) so app traffic shares dashboard buckets with web traffic. Device defaults sit under user globals (setGlobalProperties always wins), and clear() re-seeds them rather than wiping them with user state.

This also fixes tvOS, which previously fell to a generic UA that the backend classified as server traffic — no sessions, no OS breakdown.

3. watchOS support (627498a)

The README has always advertised watchOS but the package never declared it. Adds .watchOS(.v7) (needed for the WKExtension lifecycle notifications), plus app_opened/app_closed observers.

4. Real send serialization (6fdbea9)

The OperationQueue with maxConcurrentOperationCount = 1 never serialized requests — each BlockOperation spawned an unawaited Task and finished immediately, so events could reach the server out of order (a track overtaking its preceding identify). Sends are now a task chain: each awaits the previous one, with the chain tail guarded by the existing barrier queue. Profile id is resolved at track time rather than network time.

Behavior changes to release-note

  • Anonymous deviceIds shift once. deviceId = sha256(ua:ip:origin:salt), and the UA changes — existing anonymous visitors get a new deviceId after upgrading. Identified profiles are unaffected.
  • Browser column is honestly empty for app traffic. It previously showed a fake "Safari"; native apps aren't browsers, and OS/device/model reporting is now accurate instead.

Verification

swift build (macOS) and xcodebuild for iOS, tvOS, and watchOS simulators: BUILD SUCCEEDED, zero warnings on all four platforms.

🤖 Generated with Claude Code

srna and others added 10 commits July 19, 2026 20:29
ProcessInfo.operatingSystemVersionString is localized and human-readable
("Version 14.5 (Build 23F79)"), so splitting on spaces and taking index 1
breaks on non-English locales. Build the OS version token from
operatingSystemVersion's major/minor/patch components instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sdkVersion still reported 0.0.1 while the released tag is 1.0.0. This
string is sent in the openpanel-sdk-version header and embedded in every
user agent, so bump it to the next release version, 1.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OpenPanel/<version> suffix was appended before the isEmpty check, so
when the WKWebView JS evaluation failed or timed out the user agent
became " OpenPanel/x.y.z" instead of falling back to
getBasicUserAgent(). Check for the empty result first, then append the
suffix (getBasicUserAgent already appends it itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(new as AnyObject).value resolved through AnyObject dynamic lookup,
producing an Any?? that was implicitly coerced to Any (compiler
warning) and left the merged value double-wrapped. Cast to AnyCodable
and unwrap its underlying value instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
send() appended to queue and flush() read/cleared it from arbitrary
threads with no synchronization. Route both through the existing
globalQueue barrier pattern: barrier-append in send(), atomic
snapshot-and-clear in flush().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The source already guards automatic tracking with #if os(iOS) || os(tvOS),
but the platform was never declared and UIKit was only imported for iOS.
Add .tvOS(.v13) and import UIKit on tvOS so the declared platforms match
the code (WebKit is unavailable on tvOS, so the generic user agent path
is used there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK previously impersonated Safari with hand-built Mozilla strings and,
on iOS, spun up a WKWebView and blocked on a semaphore to read
navigator.userAgent — a guaranteed 1s main-thread stall when initialized on
the main thread. The backend's UA parser has a sanctioned format for app
traffic (Model=<hw>; Manufacturer=<vendor>), which sets device brand/model
and prevents the request from being classified as server traffic.

Emit OpenPanel/<version> (Model=<hw>; Manufacturer=Apple) on every platform,
using sysctl hw.model on macOS and utsname machine elsewhere (with the
SIMULATOR_MODEL_IDENTIFIER override under the simulator). This removes the
WebKit dependency entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge __os, __osVersion, __device, __brand, and __model into global
properties at initialize(). The backend prefers these property overrides to
user-agent parsing, and the values follow the ua-parser-js v2 vocabulary
(macOS/iOS/tvOS/watchOS; mobile/tablet/desktop/smarttv/wearable) so app
traffic lands in the same dashboard buckets as web traffic. Device defaults
are seeded beneath user-set globals so setGlobalProperties always wins, and
clear() re-seeds them instead of wiping them with the user state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README has always advertised watchOS, but the package never declared the
platform and the source had no watch code paths. Declare watchOS 7 (needed
for the WKExtension lifecycle notifications), import WatchKit, and register
app_opened/app_closed observers on applicationDidBecomeActive and
applicationDidEnterBackground.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OperationQueue with maxConcurrentOperationCount = 1 never serialized
anything: each BlockOperation only spawned an unawaited Task and finished
immediately, so requests raced and could arrive out of order (a track
overtaking the identify before it). Chain sends instead — each Task awaits
the previous one before hitting the network, with the chain tail guarded by
the same barrier queue as the rest of the shared state. The profile id is
now also resolved at track time rather than at network time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@srna, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08e85b15-a8f7-43f7-a109-20c1b6d413f2

📥 Commits

Reviewing files that changed from the base of the PR and between 3651435 and 6fdbea9.

📒 Files selected for processing (3)
  • Package.swift
  • README.md
  • Sources/OpenPanel/OpenPanel.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant