Data Layer Overview
Apple Search Ads uses a native attribution framework rather than a traditional web-based data layer. However, proper data structure is essential for storing attribution data, user properties, and conversion events for analytics and optimization.
Attribution Data Structure
Store Attribution Response
struct SearchAdsAttribution: Codable {
let attribution: Bool
let orgId: Int?
let campaignId: Int?
let conversionType: String?
let adGroupId: Int?
let countryOrRegion: String?
let keywordId: Int?
let adId: Int?
let clickDate: String?
func save() {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(self) {
UserDefaults.standard.set(encoded, forKey: "search_ads_attribution")
}
}
static func load() -> SearchAdsAttribution? {
if let data = UserDefaults.standard.data(forKey: "search_ads_attribution") {
let decoder = JSONDecoder()
return try? decoder.decode(SearchAdsAttribution.self, from: data)
}
return nil
}
}
Retrieve and Parse Attribution
import AdServices
func getAndStoreAttribution() {
guard let token = try? AAAttribution.attributionToken() else {
print("No attribution token available")
return
}
validateAndStoreAttribution(token: token)
}
func validateAndStoreAttribution(token: String) {
let endpoint = "https://api-adservices.apple.com/api/v1/"
var request = URLRequest(url: URL(string: endpoint)!)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = token.data(using: .utf8)
URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data,
let attribution = try? JSONDecoder().decode(SearchAdsAttribution.self, from: data) else {
return
}
// Store attribution data
attribution.save()
// Send to your analytics backend
sendToAnalytics(attribution)
}.resume()
}
User Properties Data Layer
Define User Properties
struct UserProperties {
var userId: String?
var installDate: Date
var attributionSource: String?
var campaignId: Int?
var adGroupId: Int?
var lifetimeValue: Double
var subscriptionTier: String?
var lastActiveDate: Date
func toDictionary() -> [String: Any] {
return [
"user_id": userId ?? "",
"install_date": installDate.timeIntervalSince1970,
"attribution_source": attributionSource ?? "organic",
"campaign_id": campaignId ?? 0,
"ad_group_id": adGroupId ?? 0,
"lifetime_value": lifetimeValue,
"subscription_tier": subscriptionTier ?? "free",
"last_active_date": lastActiveDate.timeIntervalSince1970
]
}
}
User Properties Manager
class UserPropertiesManager {
static let shared = UserPropertiesManager()
private var properties: UserProperties
private init() {
// Load from UserDefaults or create new
if let data = UserDefaults.standard.data(forKey: "user_properties"),
let decoded = try? JSONDecoder().decode(UserProperties.self, from: data) {
self.properties = decoded
} else {
self.properties = UserProperties(
installDate: Date(),
lifetimeValue: 0.0,
lastActiveDate: Date()
)
save()
}
}
func updateAttribution(_ attribution: SearchAdsAttribution) {
properties.attributionSource = "apple_search_ads"
properties.campaignId = attribution.campaignId
properties.adGroupId = attribution.adGroupId
save()
}
func updateLTV(amount: Double) {
properties.lifetimeValue += amount
save()
}
func save() {
if let encoded = try? JSONEncoder().encode(properties) {
UserDefaults.standard.set(encoded, forKey: "user_properties")
}
}
func getProperties() -> UserProperties {
return properties
}
}
Event Data Layer
Event Structure
struct AnalyticsEvent {
let name: String
let timestamp: Date
var parameters: [String: Any]
let conversionValue: Int?
func toDictionary() -> [String: Any] {
var dict: [String: Any] = [
"event_name": name,
"timestamp": timestamp.timeIntervalSince1970,
"parameters": parameters
]
if let value = conversionValue {
dict["conversion_value"] = value
}
// Add user properties
let userProps = UserPropertiesManager.shared.getProperties()
dict["user_properties"] = userProps.toDictionary()
return dict
}
}
Event Queue Manager
class EventQueueManager {
static let shared = EventQueueManager()
private var eventQueue: [AnalyticsEvent] = []
private let maxQueueSize = 50
func trackEvent(_ event: AnalyticsEvent) {
eventQueue.append(event)
// Auto-flush when queue is full
if eventQueue.count >= maxQueueSize {
flush()
}
}
func flush() {
guard !eventQueue.isEmpty else { return }
let eventsToSend = eventQueue
eventQueue.removeAll()
// Send to your analytics backend
sendEvents(eventsToSend)
}
private func sendEvents(_ events: [AnalyticsEvent]) {
let eventData = events.map { $0.toDictionary() }
guard let url = URL(string: "https://api.yourdomain.com/events") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: eventData)
URLSession.shared.dataTask(with: request).resume()
}
}
E-commerce Data Layer
Product Data Structure
struct Product: Codable {
let id: String
let name: String
let category: String
let price: Double
let currency: String
let quantity: Int
func toDictionary() -> [String: Any] {
return [
"product_id": id,
"product_name": name,
"category": category,
"price": price,
"currency": currency,
"quantity": quantity
]
}
}
Purchase Event
func trackPurchase(products: [Product], orderId: String, totalAmount: Double) {
let parameters: [String: Any] = [
"order_id": orderId,
"total_amount": totalAmount,
"currency": "USD",
"products": products.map { $0.toDictionary() },
"payment_method": "in_app_purchase"
]
let event = AnalyticsEvent(
name: "purchase",
timestamp: Date(),
parameters: parameters,
conversionValue: 30 // SKAdNetwork conversion value
)
EventQueueManager.shared.trackEvent(event)
// Update SKAdNetwork
if #available(iOS 14.0, *) {
SKAdNetwork.updateConversionValue(event.conversionValue ?? 0)
}
// Update user LTV
UserPropertiesManager.shared.updateLTV(amount: totalAmount)
}
Subscription Data Layer
Subscription Status
struct SubscriptionStatus: Codable {
let tier: String
let startDate: Date
let renewalDate: Date
let price: Double
let isActive: Bool
let trialPeriod: Bool
func toDictionary() -> [String: Any] {
return [
"subscription_tier": tier,
"start_date": startDate.timeIntervalSince1970,
"renewal_date": renewalDate.timeIntervalSince1970,
"price": price,
"is_active": isActive,
"trial_period": trialPeriod
]
}
}
Track Subscription Event
func trackSubscription(status: SubscriptionStatus) {
let parameters = status.toDictionary()
let conversionValue: Int
switch status.tier {
case "premium":
conversionValue = 60
case "standard":
conversionValue = 50
default:
conversionValue = 40
}
let event = AnalyticsEvent(
name: "subscribe",
timestamp: Date(),
parameters: parameters,
conversionValue: conversionValue
)
EventQueueManager.shared.trackEvent(event)
if #available(iOS 14.0, *) {
SKAdNetwork.updateConversionValue(conversionValue)
}
}
Campaign Data Integration
Link Campaign Data with Events
func enrichEventWithCampaignData(_ event: inout AnalyticsEvent) {
if let attribution = SearchAdsAttribution.load() {
event.parameters["campaign_id"] = attribution.campaignId ?? 0
event.parameters["ad_group_id"] = attribution.adGroupId ?? 0
event.parameters["keyword_id"] = attribution.keywordId ?? 0
event.parameters["country"] = attribution.countryOrRegion ?? "unknown"
event.parameters["attribution_source"] = "apple_search_ads"
} else {
event.parameters["attribution_source"] = "organic"
}
}
Server-Side Data Layer
Send Attribution to Server
func syncAttributionToServer() {
guard let attribution = SearchAdsAttribution.load() else { return }
let endpoint = URL(string: "https://api.yourdomain.com/attribution")!
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let data: [String: Any] = [
"user_id": UserPropertiesManager.shared.getProperties().userId ?? "",
"attribution_source": "apple_search_ads",
"campaign_id": attribution.campaignId ?? 0,
"ad_group_id": attribution.adGroupId ?? 0,
"keyword_id": attribution.keywordId ?? 0,
"install_date": Date().timeIntervalSince1970
]
request.httpBody = try? JSONSerialization.data(withJSONObject: data)
URLSession.shared.dataTask(with: request).resume()
}
MMP Data Layer Integration
AppsFlyer Custom Data
import AppsFlyerLib
func setAppsFlyerCustomData() {
// Set custom user properties
AppsFlyerLib.shared().customData = [
"subscription_tier": "premium",
"user_segment": "high_value",
"registration_date": "2024-01-15"
]
// Add Search Ads attribution if available
if let attribution = SearchAdsAttribution.load(),
let campaignId = attribution.campaignId {
AppsFlyerLib.shared().customData?["asa_campaign_id"] = String(campaignId)
}
}
Testing Data Layer
Debug Logging
class AnalyticsDebugger {
static func logEvent(_ event: AnalyticsEvent) {
#if DEBUG
print("Analytics Event")
print("Name: \(event.name)")
print("Timestamp: \(event.timestamp)")
print("Parameters: \(event.parameters)")
if let cv = event.conversionValue {
print("Conversion Value: \(cv)")
}
print("User Properties: \(UserPropertiesManager.shared.getProperties().toDictionary())")
print("---")
#endif
}
}
Validate Data Structure
func validateEventData(_ event: AnalyticsEvent) -> Bool {
// Check required fields
guard !event.name.isEmpty else {
print("Event name is empty")
return false
}
// Validate conversion value range
if let cv = event.conversionValue {
guard cv >= 0 && cv <= 63 else {
print("Conversion value out of range: \(cv)")
return false
}
}
return true
}
Best Practices
- Store attribution data immediately upon first app launch
- Implement event queue for batch sending to reduce network calls
- Include user properties with every event for complete context
- Use consistent naming conventions for events and parameters
- Validate data structure before sending to prevent errors
- Implement retry logic for failed event uploads
- Respect user privacy - only collect necessary data
- Document your data layer schema for team reference
- Test data layer thoroughly before production release
- Monitor data quality with server-side validation