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/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")
diff --git a/Sources/ParselyTracker.swift b/Sources/ParselyTracker.swift
index 57d3936..ecba925 100644
--- a/Sources/ParselyTracker.swift
+++ b/Sources/ParselyTracker.swift
@@ -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.
+
+ @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 = ""
@@ -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? = 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
+ ) {
+ 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(
+ 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..b8b5a4a 100644
--- a/Tests/ParselyTrackerTests.swift
+++ b/Tests/ParselyTrackerTests.swift
@@ -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
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)