From f087cbdce7463c1e4680653fe5e801e6fd5e0cc7 Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 14 May 2026 18:49:35 +0200 Subject: [PATCH 1/4] 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 2/4] 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 6f850815fb4c0361395feaf8bd5a86d559a56ce7 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 3/4] 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 3cbb75654ebfc6aef4c652a839cb4c9caa409219 Mon Sep 17 00:00:00 2001 From: Randy Date: Fri, 15 May 2026 14:38:19 +0200 Subject: [PATCH 4/4] 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