From f087cbdce7463c1e4680653fe5e801e6fd5e0cc7 Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 14 May 2026 18:49:35 +0200 Subject: [PATCH 01/10] Add trackConversion for sending conversion events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes a new public method on Parsely for firing conversion events (newsletter signups, subscriptions, purchases, link clicks, lead capture, and arbitrary custom conversions) using the existing event queue, flush, and mobileproxy pipeline. - `ConversionType` enum mirrors the categories accepted by the Parse.ly conversions backend (newsletter_signup, lead_capture, link_click, subscription, purchase, custom). - `trackConversion(url:conversionType:conversionLabel:...)` merges `_conversion_type` and `_conversion_label` into the event's `extra_data` alongside caller-supplied keys. Reserved keys can't be overridden by caller `extraData`. - The event is dispatched through `Track.conversion(...)` mirroring the existing `pageview(...)` shape — no changes to `Event`, the queue, the flush timer, or `RequestBuilder`. - New tests cover the queue path, the reserved-key guarantee, and the public Parsely.trackConversion entry point. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGES.rst | 2 +- Sources/ParselyTracker.swift | 76 +++++++++++++++++++++++++++++++++ Sources/Track.swift | 19 +++++++++ Tests/ParselyTrackerTests.swift | 17 ++++++++ Tests/TrackTests.swift | 43 +++++++++++++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ee9d498..3d8cd27 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,7 +7,7 @@ Unreleased Changes ------- -_None_ +* Added ``trackConversion(url:conversionType:conversionLabel:...)`` on ``Parsely`` for sending conversion events (newsletter signups, subscriptions, purchases, etc.). The new ``ConversionType`` enum mirrors the categories accepted by the Parse.ly conversions backend. Bugfixes -------- diff --git a/Sources/ParselyTracker.swift b/Sources/ParselyTracker.swift index 57d3936..c07ebcb 100644 --- a/Sources/ParselyTracker.swift +++ b/Sources/ParselyTracker.swift @@ -3,6 +3,20 @@ import Combine import UIKit import os.log +/** + The category of a conversion event. The raw value of each case is the string sent to Parse.ly + over the wire and must match the values accepted by the Parse.ly conversions backend. + Use `.custom` for conversions that don't fit one of the named categories. + */ +public enum ConversionType: String { + case newsletterSignup = "newsletter_signup" + case leadCapture = "lead_capture" + case linkClick = "link_click" + case subscription = "subscription" + case purchase = "purchase" + case custom = "custom" +} + public class Parsely { public var apikey = "" @@ -95,6 +109,68 @@ public class Parsely { track.pageview(url: url, urlref: urlref, metadata: metadata, extra_data: extraData, idsite: _siteId) } + /** + Track a conversion event (e.g. newsletter signup, subscription, purchase). + + - Parameter url: The url at which the conversion occurred + - Parameter conversionType: One of the supported conversion categories. Use `.custom` for + conversions that don't fit the named categories. + - Parameter conversionLabel: A customer-defined identifier for this conversion + (e.g. "weekly_plan", "homepage_cta"). Events without a label are dropped by the Parse.ly + conversions backend. + - Parameter urlref: The url of the page that linked to the conversion page + - Parameter metadata: Metadata for the page on which the conversion occurred + - Parameter extraData: A dictionary of additional information to send with the event. + Reserved keys `_conversion_type` and `_conversion_label` will be overwritten. + - Parameter siteId: The Parsely site ID for which the conversion event should be counted + */ + public func trackConversion( + url: String, + conversionType: ConversionType, + conversionLabel: String, + urlref: String = "", + metadata: ParselyMetadata? = nil, + extraData: Dictionary? = nil, + siteId: String = "" + ) { + eventProcessor.async { + self._trackConversion( + url: url, + conversionType: conversionType, + conversionLabel: conversionLabel, + urlref: urlref, + metadata: metadata, + extraData: extraData, + siteId: siteId + ) + } + } + + private func _trackConversion( + url: String, + conversionType: ConversionType, + conversionLabel: String, + urlref: String, + metadata: ParselyMetadata?, + extraData: Dictionary?, + siteId: String + ) { + var _siteId = siteId + if _siteId == "" { + _siteId = self.apikey + } + os_log("Tracking Conversion", log: OSLog.tracker, type: .debug) + track.conversion( + url: url, + urlref: urlref, + conversionType: conversionType.rawValue, + conversionLabel: conversionLabel, + metadata: metadata, + extra_data: extraData, + idsite: _siteId + ) + } + /** Start tracking engaged time for a given url. Once called, heartbeat events will be sent periodically for this url until engaged time tracking is stopped. Stops tracking engaged time for any urls currently being tracked for engaged diff --git a/Sources/Track.swift b/Sources/Track.swift index 69090f2..3101a51 100644 --- a/Sources/Track.swift +++ b/Sources/Track.swift @@ -39,6 +39,25 @@ class Track { event(event: event_) } + func conversion(url: String, urlref: String = "", conversionType: String, conversionLabel: String, + metadata: ParselyMetadata?, extra_data: Dictionary?, idsite: String) { + var merged = extra_data ?? [:] + merged["_conversion_type"] = conversionType + merged["_conversion_label"] = conversionLabel + + let event_ = Event( + "conversion", + url: url, + urlref: urlref, + metadata: metadata, + extra_data: merged, + idsite: idsite + ) + + os_log("Sending a conversion from Track", log: OSLog.tracker, type: .debug) + event(event: event_) + } + func videoStart(url: String, urlref: String, vId: String, duration: TimeInterval, metadata: ParselyMetadata?, extra_data: Dictionary?, idsite: String) { videoManager.trackPlay(url: url, urlref: urlref, vId: vId, duration: duration, metadata: metadata, extra_data: extra_data, idsite: idsite) os_log("Tracked videoStart from Track", log: OSLog.tracker, type: .debug) diff --git a/Tests/ParselyTrackerTests.swift b/Tests/ParselyTrackerTests.swift index 3b5ecfe..a219050 100644 --- a/Tests/ParselyTrackerTests.swift +++ b/Tests/ParselyTrackerTests.swift @@ -26,6 +26,23 @@ class ParselyTrackerTests: ParselyTestCase { expectParselyState(self.parselyTestTracker.eventQueue.length()).toEventually(equal(1)) } + func testTrackConversion() { + XCTAssertEqual(parselyTestTracker.eventQueue.length(), 0, + "eventQueue should be empty immediately after initialization") + parselyTestTracker.trackConversion( + url: testUrl, + conversionType: .subscription, + conversionLabel: "weekly_plan" + ) + // A call to Parsely.trackConversion should add an event to eventQueue + expectParselyState(self.parselyTestTracker.eventQueue.length()).toEventually(equal(1)) + expectParselyState(self.parselyTestTracker.eventQueue.list.first?.action).toEventually(equal("conversion")) + expectParselyState(self.parselyTestTracker.eventQueue.list.first?.extra_data["_conversion_type"] as? String) + .toEventually(equal("subscription")) + expectParselyState(self.parselyTestTracker.eventQueue.list.first?.extra_data["_conversion_label"] as? String) + .toEventually(equal("weekly_plan")) + } + func testStartEngagement() { parselyTestTracker.startEngagement(url: testUrl) // After a call to Parsely.startEngagement, the internal accumulator for the engaged url should exist diff --git a/Tests/TrackTests.swift b/Tests/TrackTests.swift index 11b8b6a..3c8442a 100644 --- a/Tests/TrackTests.swift +++ b/Tests/TrackTests.swift @@ -28,6 +28,49 @@ class TrackTests: ParselyTestCase { "A call to Track.pageview should add an event to eventQueue") } + func testConversion() { + XCTAssertEqual(parselyTestTracker.eventQueue.length(), 0, + "eventQueue should be empty immediately after initialization") + track!.conversion( + url: testUrl, + urlref: testUrl, + conversionType: "subscription", + conversionLabel: "weekly_plan", + metadata: nil, + extra_data: ["plan": "Active"], + idsite: Parsely.testAPIKey + ) + XCTAssertEqual(parselyTestTracker.eventQueue.length(), 1, + "A call to Track.conversion should add an event to eventQueue") + + let queued = parselyTestTracker.eventQueue.list.first! + XCTAssertEqual(queued.action, "conversion", + "Track.conversion should produce an event whose action is \"conversion\"") + XCTAssertEqual(queued.extra_data["_conversion_type"] as? String, "subscription", + "Track.conversion should merge _conversion_type into extra_data") + XCTAssertEqual(queued.extra_data["_conversion_label"] as? String, "weekly_plan", + "Track.conversion should merge _conversion_label into extra_data") + XCTAssertEqual(queued.extra_data["plan"] as? String, "Active", + "Track.conversion should preserve caller-supplied extra_data values") + } + + func testConversionReservedKeysOverwriteCallerExtraData() { + track!.conversion( + url: testUrl, + urlref: testUrl, + conversionType: "purchase", + conversionLabel: "cta_pricing", + metadata: nil, + extra_data: ["_conversion_type": "spoofed", "_conversion_label": "spoofed"], + idsite: Parsely.testAPIKey + ) + let queued = parselyTestTracker.eventQueue.list.first! + XCTAssertEqual(queued.extra_data["_conversion_type"] as? String, "purchase", + "Reserved key _conversion_type must not be overridable via caller extra_data") + XCTAssertEqual(queued.extra_data["_conversion_label"] as? String, "cta_pricing", + "Reserved key _conversion_label must not be overridable via caller extra_data") + } + func testVideoStart() { track!.videoStart(url: testUrl, urlref: testUrl, vId: testVideoId, duration: TimeInterval(10), metadata: nil, extra_data: nil, idsite: Parsely.testAPIKey) From f22c7b997e3c60f30c669e3ccc3af43cb12d706b Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 14 May 2026 18:49:51 +0200 Subject: [PATCH 02/10] Demo: add sandbox buttons for trackConversion smoke testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two buttons on the First tab — "Track Pageview (sandbox)" and "Track Conversion (sandbox)" — that fire against the `sandbox.joshhanson.io` apikey on a shared test URL. The pageview lets a session accrue history before the conversion fires so the conversions topology has something to attribute to. Used during the manual end-to-end verification of trackConversion: with Proxyman attached, confirm action=conversion and the _conversion_type / _conversion_label keys land in the on-wire payload, then watch dash.parsely.com for the conversion in the conversions report. Co-Authored-By: Claude Opus 4.7 (1M context) --- Demo/ParselyDemo/Base.lproj/Main.storyboard | 20 ++++++++++++++++ Demo/ParselyDemo/FirstViewController.swift | 26 +++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Demo/ParselyDemo/Base.lproj/Main.storyboard b/Demo/ParselyDemo/Base.lproj/Main.storyboard index 645111f..020e505 100644 --- a/Demo/ParselyDemo/Base.lproj/Main.storyboard +++ b/Demo/ParselyDemo/Base.lproj/Main.storyboard @@ -66,6 +66,26 @@ + + + diff --git a/Demo/ParselyDemo/FirstViewController.swift b/Demo/ParselyDemo/FirstViewController.swift index 21f473f..f1ea458 100644 --- a/Demo/ParselyDemo/FirstViewController.swift +++ b/Demo/ParselyDemo/FirstViewController.swift @@ -5,12 +5,38 @@ import ParselyAnalytics class FirstViewController: UIViewController { let delegate = UIApplication.shared.delegate as! AppDelegate + // Sandbox test URL for the conversion-tracking smoke flow. Both the pageview and + // the conversion buttons fire against this URL on the `sandbox.joshhanson.io` apikey + // so pageview history and conversion events share a visitor session in the backend. + private let sandboxUrl = "https://sandbox.joshhanson.io/path/test-conversion2" + private let sandboxSiteId = "sandbox.joshhanson.io" + @IBAction func didTouchButton(_ sender: Any) { log("didTouchButton") let demoMetas = ParselyMetadata(authors: ["Yogi Berr"]) delegate.parsely.trackPageView(url: "http://parsely.com/path/cool-blog-post/1?qsarg=nawp&anotherone=yup", metadata: demoMetas, extraData: ["product-id": "12345"], siteId: "subdomain.parsely-test.com") } + @IBAction func didTouchSandboxPageview(_ sender: Any) { + log("didTouchSandboxPageview") + delegate.parsely.trackPageView( + url: sandboxUrl, + extraData: ["source": "ios_demo_app"], + siteId: sandboxSiteId + ) + } + + @IBAction func didTouchSandboxConversion(_ sender: Any) { + log("didTouchSandboxConversion") + delegate.parsely.trackConversion( + url: sandboxUrl, + conversionType: .subscription, + conversionLabel: "ios_smoke_test_v2", + extraData: ["plan": "weekly", "source": "ios_demo_app"], + siteId: sandboxSiteId + ) + } + @IBAction func didStartEngagement(_ sender: Any) { log("didStartEngagement") delegate.parsely.startEngagement(url: "http://parsely.com/very-not-real", urlref: "http://parsely.com/not-real", extraData: ["product-id": "12345"], siteId: "engaged.parsely-test.com") From 744cceb5467116b4f522c283ac39c7c45b2660d4 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 15 May 2026 11:41:16 +1000 Subject: [PATCH 03/10] Actually use latest macOS in GHA --- .github/workflows/ios.yml | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 1e176e3..b95748e 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -14,19 +14,7 @@ jobs: include: # Only using "latest" CI for the moment. # We'll decide later whether to keep running tests on older environments - # - # - macos: "macos-10.15" - # xcode: "11.6" - # ios: "13.6" - # iphone: "iPhone 11" - # - macos: "macos-11" - # xcode: "12.4" - # ios: "14.4" - # iphone: "iPhone 12" - - macos: "macos-12" - xcode: "14.1" - ios: "16.1" - iphone: "iPhone 14" + - macos: "latest" runs-on: ${{ matrix.macos }} steps: - uses: actions/checkout@v3 From 36bb9ba35de6d27cf323ef5bbf0d5137b3f2bcf0 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 15 May 2026 11:41:27 +1000 Subject: [PATCH 04/10] Use Ruby 3.3.4 Chose despite being out of active maintenance because it's the latest version currently supported by Automattic's CI. Granted, we don't have Automattic's CI in this repo, but at least we can keep things consistent, as we'll get there _eventually_. --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index a4dd9db..a0891f5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.4 +3.3.4 From cc608420056d041e8551961d3539328721869b5a Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 15 May 2026 11:42:40 +1000 Subject: [PATCH 05/10] Run tests via `make` on iPhone 16 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e48ec50..538320c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # TODO: Use newer Sim. Sticking with it at the moment because we know it works in the existing GitHub action setup. -SIMULATOR_NAME ?= iPhone 14 +SIMULATOR_NAME ?= iPhone 17 SIMULATOR_OS ?= latest XCODE_PATH ?= /Applications/Xcode.app From 6e548162898da300fd41fa7ab1fdb46188bb8406 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 15 May 2026 11:42:54 +1000 Subject: [PATCH 06/10] Account for patch OS version in unit tests Discovered because current iOS version on my machine is 26.4.1 --- Tests/RequestBuilderTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/RequestBuilderTests.swift b/Tests/RequestBuilderTests.swift index c58e70e..b86a902 100644 --- a/Tests/RequestBuilderTests.swift +++ b/Tests/RequestBuilderTests.swift @@ -75,6 +75,6 @@ class RequestBuilderTests: XCTestCase { // be in the format // // xctest/ iOS/ () - expect(RequestBuilder.getUserAgent()).to(match("xctest\\/\\d+\\.\\d+(\\.\\d+)? iOS\\/\\d+\\.\\d+ (.*)")) + expect(RequestBuilder.getUserAgent()).to(match("xctest\\/\\d+\\.\\d+(\\.\\d+)? iOS\\/\\d+\\.\\d+(\\.\\d+)? (.*)")) } } From 8bf78dcdc72d6a1176c33470d6faeb65003114fd Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 15 May 2026 14:11:43 +1000 Subject: [PATCH 07/10] Fix GHA runner syntax! Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index b95748e..9fdf16c 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -14,7 +14,7 @@ jobs: include: # Only using "latest" CI for the moment. # We'll decide later whether to keep running tests on older environments - - macos: "latest" + - macos: "macos-latest" runs-on: ${{ matrix.macos }} steps: - uses: actions/checkout@v3 From 3945cb42d94ea4d5013a031b0d1415343297f673 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 18 May 2026 10:42:28 +1000 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 538320c..7143c6c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# TODO: Use newer Sim. Sticking with it at the moment because we know it works in the existing GitHub action setup. +# Default simulator used for local runs and CI. SIMULATOR_NAME ?= iPhone 17 SIMULATOR_OS ?= latest XCODE_PATH ?= /Applications/Xcode.app From 67c50a2dfcbcd57f425e80b53dac89fd891f50f3 Mon Sep 17 00:00:00 2001 From: randy <82468005+randyriback@users.noreply.github.com> Date: Fri, 15 May 2026 14:21:31 +0200 Subject: [PATCH 09/10] Update Sources/ParselyTracker.swift Co-authored-by: Gio Lodi --- Sources/ParselyTracker.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/ParselyTracker.swift b/Sources/ParselyTracker.swift index c07ebcb..f3121b4 100644 --- a/Sources/ParselyTracker.swift +++ b/Sources/ParselyTracker.swift @@ -7,6 +7,8 @@ import os.log The category of a conversion event. The raw value of each case is the string sent to Parse.ly over the wire and must match the values accepted by the Parse.ly conversions backend. Use `.custom` for conversions that don't fit one of the named categories. + + @See: https://docs.parse.ly/api/api-endpoints/api-conversions-endpoint/ */ public enum ConversionType: String { case newsletterSignup = "newsletter_signup" From f58b906ca9fd9cdebff86222661845f87811cbce Mon Sep 17 00:00:00 2001 From: Randy Date: Fri, 15 May 2026 14:38:19 +0200 Subject: [PATCH 10/10] Guard trackConversion against empty conversionLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback (#PR): the doc promised the conversions backend drops events without a label, but the implementation would still enqueue and send them — a guaranteed no-op event from the customer's perspective. Now `_trackConversion` early-returns with a `.error`-level os_log when `conversionLabel.isEmpty`, so the SDK matches its own documentation and gives the developer a loud signal during integration instead of silently shipping events that will never appear in reporting. - Public doc updated to reflect the SDK-side skip. - New test `testTrackConversionWithEmptyLabelDoesNotEnqueue` verifies an empty label does not produce a queued event. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/ParselyTracker.swift | 10 ++++++++-- Tests/ParselyTrackerTests.swift | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/ParselyTracker.swift b/Sources/ParselyTracker.swift index f3121b4..ecba925 100644 --- a/Sources/ParselyTracker.swift +++ b/Sources/ParselyTracker.swift @@ -118,8 +118,9 @@ public class Parsely { - Parameter conversionType: One of the supported conversion categories. Use `.custom` for conversions that don't fit the named categories. - Parameter conversionLabel: A customer-defined identifier for this conversion - (e.g. "weekly_plan", "homepage_cta"). Events without a label are dropped by the Parse.ly - conversions backend. + (e.g. "weekly_plan", "homepage_cta"). Required and must be non-empty — the Parse.ly + conversions backend drops events without a label, so calls with an empty label are + skipped before they are enqueued and an error is logged. - Parameter urlref: The url of the page that linked to the conversion page - Parameter metadata: Metadata for the page on which the conversion occurred - Parameter extraData: A dictionary of additional information to send with the event. @@ -157,6 +158,11 @@ public class Parsely { extraData: Dictionary?, siteId: String ) { + guard !conversionLabel.isEmpty else { + os_log("conversionLabel cannot be empty. Parse.ly's conversions backend drops events without a label, so this call is being skipped.", + log: OSLog.tracker, type: .error) + return + } var _siteId = siteId if _siteId == "" { _siteId = self.apikey diff --git a/Tests/ParselyTrackerTests.swift b/Tests/ParselyTrackerTests.swift index a219050..b8b5a4a 100644 --- a/Tests/ParselyTrackerTests.swift +++ b/Tests/ParselyTrackerTests.swift @@ -43,6 +43,21 @@ class ParselyTrackerTests: ParselyTestCase { .toEventually(equal("weekly_plan")) } + func testTrackConversionWithEmptyLabelDoesNotEnqueue() { + XCTAssertEqual(parselyTestTracker.eventQueue.length(), 0, + "eventQueue should be empty immediately after initialization") + parselyTestTracker.trackConversion( + url: testUrl, + conversionType: .subscription, + conversionLabel: "" + ) + // A call to Parsely.trackConversion with an empty conversionLabel should be skipped + // before reaching the queue, because the Parse.ly conversions backend drops such events. + // The `expectParselyState` helper drains the eventProcessor queue, so by the time the + // assertion runs, the trackConversion async block has fully executed (and returned early). + expectParselyState(self.parselyTestTracker.eventQueue.length()).to(equal(0)) + } + func testStartEngagement() { parselyTestTracker.startEngagement(url: testUrl) // After a call to Parsely.startEngagement, the internal accumulator for the engaged url should exist