Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------
Expand Down
20 changes: 20 additions & 0 deletions Demo/ParselyDemo/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@
<action selector="didPauseVideo:" destination="9pv-A4-QxB" eventType="primaryActionTriggered" id="TbX-BH-W7q"/>
</connections>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sandbox" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rrx-sb-lbl">
<rect key="frame" x="0.0" y="226" width="125" height="20"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rrx-sb-pgv">
<rect key="frame" x="0.0" y="251" width="125" height="30"/>
<state key="normal" title="Track Pageview (sandbox)"/>
<connections>
<action selector="didTouchSandboxPageview:" destination="9pv-A4-QxB" eventType="primaryActionTriggered" id="rrx-sb-pv1"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rrx-sb-cnv">
<rect key="frame" x="0.0" y="286" width="125" height="30"/>
<state key="normal" title="Track Conversion v2 (sandbox)"/>
<connections>
<action selector="didTouchSandboxConversion:" destination="9pv-A4-QxB" eventType="primaryActionTriggered" id="rrx-sb-cn1"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
Expand Down
26 changes: 26 additions & 0 deletions Demo/ParselyDemo/FirstViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
84 changes: 84 additions & 0 deletions Sources/ParselyTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ 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.
Comment thread
randyriback marked this conversation as resolved.

@See: https://docs.parse.ly/api/api-endpoints/api-conversions-endpoint/
*/
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 = ""
Expand Down Expand Up @@ -95,6 +111,74 @@ 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"). 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.
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<String, Any>? = 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<String, Any>?,
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
}
os_log("Tracking Conversion", log: OSLog.tracker, type: .debug)
track.conversion(
Comment on lines +152 to +171

@mokagio mokagio May 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@randyriback I think this is a legit concern. However, I'm not sure logging an error and failing silently would be a good experience for the users.

I'd love to require a NonEmpty<String> but that might be too overbearing on the users, too.

Maybe we can accept the ambiguity for the time being and follow up at some point with a better SDK design. One that throws errors or that has DTOs with validation in the init as input parameters for the tracking calls.

What do you think?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Gio! Implemented Copilot's suggestion. Added a guard in _trackConversion-- if conversionLabel is empty, the SDK logs an .error via os_log and returns before enqueuing — same shape as the existing empty-idsite guard in Track.swift. Updated the doc on the public method to reflect this, and added testTrackConversionWithEmptyLabelDoesNotEnqueue

Skipped the NonEmpty route per your concern about caller ergonomics. Happy to tackle the broader DTO-with-validation refactor as a follow-up if/when we want to do it across all tracking methods.

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
Expand Down
19 changes: 19 additions & 0 deletions Sources/Track.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ class Track {
event(event: event_)
}

func conversion(url: String, urlref: String = "", conversionType: String, conversionLabel: String,
metadata: ParselyMetadata?, extra_data: Dictionary<String, Any>?, 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<String, Any>?, 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)
Expand Down
32 changes: 32 additions & 0 deletions Tests/ParselyTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ 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 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
Expand Down
43 changes: 43 additions & 0 deletions Tests/TrackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading