@bigcrunch/react-native-ads 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +7 -4
- package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +8 -1
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +37 -12
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +199 -40
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +52 -17
- package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +1 -1
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +81 -2
- package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +4 -1
- package/android/src/main/java/com/bigcrunch/ads/react/BigCrunchAdsModule.kt +44 -2
- package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +7 -0
- package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +10 -6
- package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +10 -2
- package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +20 -4
- package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +179 -77
- package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +54 -17
- package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +1 -1
- package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +111 -3
- package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +4 -1
- package/ios/BigCrunchAdsModule.m +1 -0
- package/ios/BigCrunchAdsModule.swift +29 -2
- package/ios/BigCrunchBannerViewManager.swift +1 -0
- package/lib/BigCrunchAds.d.ts +19 -2
- package/lib/BigCrunchAds.d.ts.map +1 -1
- package/lib/BigCrunchAds.js +19 -2
- package/lib/NativeBigCrunchAds.d.ts +2 -2
- package/lib/NativeBigCrunchAds.d.ts.map +1 -1
- package/lib/types/index.d.ts +36 -0
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/react-native-bigcrunch-ads.podspec +1 -1
- package/src/BigCrunchAds.ts +23 -2
- package/src/NativeBigCrunchAds.ts +2 -2
- package/src/types/index.ts +38 -0
|
@@ -26,9 +26,18 @@ internal class AnalyticsClient {
|
|
|
26
26
|
private var impressionContexts: [String: ImpressionContext] = [:]
|
|
27
27
|
private let contextLock = NSLock()
|
|
28
28
|
|
|
29
|
+
/// Per-placement auction data from S2S responses (set by AdOrchestrator, consumed by trackAdImpression)
|
|
30
|
+
private var placementAuctionData: [String: AuctionData] = [:]
|
|
31
|
+
|
|
29
32
|
/// Previous screen name for referrer tracking
|
|
30
33
|
private var previousScreenName: String?
|
|
31
34
|
|
|
35
|
+
/// Current page URL (set by trackScreenView, used by all events)
|
|
36
|
+
private var currentPageUrl: String = ""
|
|
37
|
+
|
|
38
|
+
/// Current custom dimensions (set by trackScreenView, used by all events)
|
|
39
|
+
private var currentCustomDimensions: [String: String] = [:]
|
|
40
|
+
|
|
32
41
|
/// Batching for impressions (250ms delay)
|
|
33
42
|
private var impressionBatch: [ImpressionBatchEvent] = []
|
|
34
43
|
private var impressionBatchTimer: Timer?
|
|
@@ -127,15 +136,34 @@ internal class AnalyticsClient {
|
|
|
127
136
|
|
|
128
137
|
// MARK: - Page View Tracking
|
|
129
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Generate the default page URL from screen name
|
|
141
|
+
*/
|
|
142
|
+
private func generatePageUrl(_ screenName: String) -> String {
|
|
143
|
+
let appName = BigCrunchAds.getAppConfig()?.appName ?? "app"
|
|
144
|
+
let sanitizedAppName = appName.lowercased()
|
|
145
|
+
.replacingOccurrences(of: " ", with: "-")
|
|
146
|
+
.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? appName
|
|
147
|
+
let encodedScreen = screenName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? screenName
|
|
148
|
+
return "https://\(sanitizedAppName).mobile.app/\(encodedScreen)"
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
/**
|
|
131
152
|
* Track a screen view
|
|
132
153
|
*
|
|
133
|
-
* -
|
|
154
|
+
* - Parameters:
|
|
155
|
+
* - screenName: Name of the screen being viewed
|
|
156
|
+
* - options: Optional overrides for page URL, metadata, and custom dimensions
|
|
134
157
|
*/
|
|
135
|
-
func trackScreenView(_ screenName: String) {
|
|
158
|
+
func trackScreenView(_ screenName: String, options: ScreenViewOptions? = nil) {
|
|
136
159
|
// Start new page view and get IDs
|
|
137
160
|
let pageId = sessionManager.startPageView()
|
|
138
161
|
|
|
162
|
+
// Resolve page URL: explicit override > auto-generated
|
|
163
|
+
let pageUrl = options?.pageUrl ?? generatePageUrl(screenName)
|
|
164
|
+
currentPageUrl = pageUrl
|
|
165
|
+
currentCustomDimensions = options?.customDimensions ?? [:]
|
|
166
|
+
|
|
139
167
|
// Get common event fields
|
|
140
168
|
let common = getCommonEventFields()
|
|
141
169
|
|
|
@@ -155,7 +183,7 @@ internal class AnalyticsClient {
|
|
|
155
183
|
newUser: sessionManager.isNewUser,
|
|
156
184
|
pageId: pageId,
|
|
157
185
|
sessionDepth: sessionManager.sessionDepth,
|
|
158
|
-
pageUrl:
|
|
186
|
+
pageUrl: pageUrl,
|
|
159
187
|
pageSearch: "",
|
|
160
188
|
pageReferrer: "", // page_referrer must be valid URL or empty
|
|
161
189
|
browser: common.browser,
|
|
@@ -176,7 +204,8 @@ internal class AnalyticsClient {
|
|
|
176
204
|
diiSource: "",
|
|
177
205
|
gamNetworkCode: gamNetworkCode,
|
|
178
206
|
amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
|
|
179
|
-
customDimensions:
|
|
207
|
+
customDimensions: currentCustomDimensions,
|
|
208
|
+
pageMetaData: options?.pageMeta
|
|
180
209
|
)
|
|
181
210
|
|
|
182
211
|
sendEvent(event, endpoint: "pageviews")
|
|
@@ -249,6 +278,37 @@ internal class AnalyticsClient {
|
|
|
249
278
|
return impressionContexts[impressionId]
|
|
250
279
|
}
|
|
251
280
|
|
|
281
|
+
// MARK: - Auction Data Storage
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
|
|
285
|
+
*/
|
|
286
|
+
func setAuctionData(placementId: String, auctionData: AuctionData) {
|
|
287
|
+
contextLock.lock()
|
|
288
|
+
placementAuctionData[placementId] = auctionData
|
|
289
|
+
contextLock.unlock()
|
|
290
|
+
BCLogger.debug("Stored auction data for placement: \(placementId) (channel: \(auctionData.demandChannel ?? "unknown"))")
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Consume auction data for a placement (returns and removes stored data)
|
|
295
|
+
*/
|
|
296
|
+
private func consumeAuctionData(placementId: String) -> AuctionData? {
|
|
297
|
+
contextLock.lock()
|
|
298
|
+
let data = placementAuctionData.removeValue(forKey: placementId)
|
|
299
|
+
contextLock.unlock()
|
|
300
|
+
return data
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
|
|
305
|
+
*/
|
|
306
|
+
private func computeMinBidToWin(_ auctionData: AuctionData?) -> Double {
|
|
307
|
+
guard let data = auctionData else { return 0.0 }
|
|
308
|
+
let baseline = max(data.secondHighestBid ?? 0.0, data.floorPrice ?? 0.0)
|
|
309
|
+
return baseline > 0 ? baseline + 0.01 : 0.0
|
|
310
|
+
}
|
|
311
|
+
|
|
252
312
|
// MARK: - Impression Tracking
|
|
253
313
|
|
|
254
314
|
/**
|
|
@@ -278,27 +338,31 @@ internal class AnalyticsClient {
|
|
|
278
338
|
? context.auctionData.auctionId!
|
|
279
339
|
: UUID().uuidString
|
|
280
340
|
|
|
341
|
+
// Look up stored auction data (may have been set by AdOrchestrator)
|
|
342
|
+
let storedAuctionData = consumeAuctionData(placementId: context.placementId)
|
|
343
|
+
let effectiveAuctionData = storedAuctionData ?? context.auctionData
|
|
344
|
+
|
|
281
345
|
// Create the impression record
|
|
282
346
|
let impressionRecord = ImpressionRecord(
|
|
283
347
|
slotId: context.placementId,
|
|
284
348
|
gamUnit: context.gamAdUnit,
|
|
285
|
-
gamPriceBucket: "",
|
|
349
|
+
gamPriceBucket: effectiveAuctionData.gamPriceBucket ?? "",
|
|
286
350
|
impressionId: context.impressionId,
|
|
287
351
|
auctionId: auctionId, // Must be valid UUID
|
|
288
352
|
refreshCount: 0,
|
|
289
|
-
adBidder:
|
|
353
|
+
adBidder: effectiveAuctionData.bidder ?? "",
|
|
290
354
|
adSize: adSize,
|
|
291
|
-
adPrice:
|
|
292
|
-
adFloorPrice: 0.0,
|
|
293
|
-
minBidToWin:
|
|
355
|
+
adPrice: effectiveAuctionData.bidPriceCpm ?? 0.0,
|
|
356
|
+
adFloorPrice: effectiveAuctionData.floorPrice ?? 0.0,
|
|
357
|
+
minBidToWin: computeMinBidToWin(effectiveAuctionData),
|
|
294
358
|
advertiserId: "",
|
|
295
359
|
campaignId: "",
|
|
296
360
|
lineItemId: "",
|
|
297
|
-
creativeId:
|
|
361
|
+
creativeId: effectiveAuctionData.creativeId ?? "",
|
|
298
362
|
adAmznbid: "",
|
|
299
363
|
adAmznp: "",
|
|
300
|
-
adDemandType:
|
|
301
|
-
demandChannel: "",
|
|
364
|
+
adDemandType: context.format,
|
|
365
|
+
demandChannel: effectiveAuctionData.demandChannel ?? "Google Ad Exchange",
|
|
302
366
|
customDimensions: [:]
|
|
303
367
|
)
|
|
304
368
|
|
|
@@ -313,7 +377,7 @@ internal class AnalyticsClient {
|
|
|
313
377
|
newUser: sessionManager.isNewUser,
|
|
314
378
|
pageId: sessionManager.getOrCreatePageId(),
|
|
315
379
|
sessionDepth: sessionManager.sessionDepth,
|
|
316
|
-
pageUrl:
|
|
380
|
+
pageUrl: currentPageUrl,
|
|
317
381
|
pageSearch: "",
|
|
318
382
|
pageReferrer: "",
|
|
319
383
|
browser: common.browser,
|
|
@@ -334,7 +398,7 @@ internal class AnalyticsClient {
|
|
|
334
398
|
diiSource: "",
|
|
335
399
|
gamNetworkCode: gamNetworkCode,
|
|
336
400
|
amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
|
|
337
|
-
customDimensions:
|
|
401
|
+
customDimensions: currentCustomDimensions,
|
|
338
402
|
impressions: [impressionRecord]
|
|
339
403
|
)
|
|
340
404
|
|
|
@@ -342,11 +406,13 @@ internal class AnalyticsClient {
|
|
|
342
406
|
}
|
|
343
407
|
|
|
344
408
|
/**
|
|
345
|
-
* Track an ad impression (simple version
|
|
409
|
+
* Track an ad impression (simple version with auction data lookup)
|
|
346
410
|
*/
|
|
347
411
|
func trackAdImpression(
|
|
348
412
|
placementId: String,
|
|
349
413
|
format: String,
|
|
414
|
+
gamAdUnit: String = "",
|
|
415
|
+
adSize: String = "",
|
|
350
416
|
refreshCount: Int = 0,
|
|
351
417
|
advertiserId: String? = nil,
|
|
352
418
|
campaignId: String? = nil,
|
|
@@ -362,27 +428,35 @@ internal class AnalyticsClient {
|
|
|
362
428
|
// Region must be 2 chars or empty per backend validation
|
|
363
429
|
let regionCode = common.region.count == 2 ? common.region : ""
|
|
364
430
|
|
|
365
|
-
//
|
|
431
|
+
// Look up stored auction data from S2S response
|
|
432
|
+
let auctionData = consumeAuctionData(placementId: placementId)
|
|
433
|
+
|
|
434
|
+
// auction_id: use S2S auction ID if available, otherwise generate one
|
|
435
|
+
let auctionId = auctionData?.auctionId?.isEmpty == false
|
|
436
|
+
? auctionData!.auctionId!
|
|
437
|
+
: UUID().uuidString
|
|
438
|
+
|
|
439
|
+
// Create the impression record with auction data fields populated
|
|
366
440
|
let impressionRecord = ImpressionRecord(
|
|
367
441
|
slotId: placementId,
|
|
368
|
-
gamUnit:
|
|
369
|
-
gamPriceBucket: "",
|
|
442
|
+
gamUnit: gamAdUnit,
|
|
443
|
+
gamPriceBucket: auctionData?.gamPriceBucket ?? "",
|
|
370
444
|
impressionId: UUID().uuidString,
|
|
371
|
-
auctionId:
|
|
445
|
+
auctionId: auctionId,
|
|
372
446
|
refreshCount: refreshCount,
|
|
373
|
-
adBidder: "",
|
|
374
|
-
adSize:
|
|
375
|
-
adPrice: 0.0,
|
|
376
|
-
adFloorPrice: 0.0,
|
|
377
|
-
minBidToWin:
|
|
447
|
+
adBidder: auctionData?.bidder ?? "",
|
|
448
|
+
adSize: adSize,
|
|
449
|
+
adPrice: auctionData?.bidPriceCpm ?? 0.0,
|
|
450
|
+
adFloorPrice: auctionData?.floorPrice ?? 0.0,
|
|
451
|
+
minBidToWin: computeMinBidToWin(auctionData),
|
|
378
452
|
advertiserId: advertiserId ?? "",
|
|
379
453
|
campaignId: campaignId ?? "",
|
|
380
454
|
lineItemId: lineItemId ?? "",
|
|
381
|
-
creativeId: creativeId ?? "",
|
|
455
|
+
creativeId: auctionData?.creativeId ?? creativeId ?? "",
|
|
382
456
|
adAmznbid: "",
|
|
383
457
|
adAmznp: "",
|
|
384
|
-
adDemandType:
|
|
385
|
-
demandChannel: "",
|
|
458
|
+
adDemandType: format,
|
|
459
|
+
demandChannel: auctionData?.demandChannel ?? "Google Ad Exchange",
|
|
386
460
|
customDimensions: [:]
|
|
387
461
|
)
|
|
388
462
|
|
|
@@ -397,7 +471,7 @@ internal class AnalyticsClient {
|
|
|
397
471
|
newUser: sessionManager.isNewUser,
|
|
398
472
|
pageId: sessionManager.getOrCreatePageId(),
|
|
399
473
|
sessionDepth: sessionManager.sessionDepth,
|
|
400
|
-
pageUrl:
|
|
474
|
+
pageUrl: currentPageUrl,
|
|
401
475
|
pageSearch: "",
|
|
402
476
|
pageReferrer: "",
|
|
403
477
|
browser: common.browser,
|
|
@@ -418,7 +492,7 @@ internal class AnalyticsClient {
|
|
|
418
492
|
diiSource: "",
|
|
419
493
|
gamNetworkCode: gamNetworkCode,
|
|
420
494
|
amznPubId: AnalyticsClient.nilUUID, // amzn_pub_id must be valid UUID
|
|
421
|
-
customDimensions:
|
|
495
|
+
customDimensions: currentCustomDimensions,
|
|
422
496
|
impressions: [impressionRecord]
|
|
423
497
|
)
|
|
424
498
|
|
|
@@ -474,7 +548,7 @@ internal class AnalyticsClient {
|
|
|
474
548
|
newUser: sessionManager.isNewUser,
|
|
475
549
|
pageId: sessionManager.getOrCreatePageId(),
|
|
476
550
|
sessionDepth: sessionManager.sessionDepth,
|
|
477
|
-
pageUrl:
|
|
551
|
+
pageUrl: currentPageUrl,
|
|
478
552
|
pageSearch: "",
|
|
479
553
|
pageReferrer: "",
|
|
480
554
|
browser: common.browser,
|
|
@@ -495,7 +569,7 @@ internal class AnalyticsClient {
|
|
|
495
569
|
diiSource: "",
|
|
496
570
|
gamNetworkCode: gamNetworkCode,
|
|
497
571
|
amznPubId: AnalyticsClient.nilUUID,
|
|
498
|
-
customDimensions:
|
|
572
|
+
customDimensions: currentCustomDimensions,
|
|
499
573
|
click: clickData
|
|
500
574
|
)
|
|
501
575
|
|
|
@@ -564,7 +638,7 @@ internal class AnalyticsClient {
|
|
|
564
638
|
newUser: sessionManager.isNewUser,
|
|
565
639
|
pageId: sessionManager.getOrCreatePageId(),
|
|
566
640
|
sessionDepth: sessionManager.sessionDepth,
|
|
567
|
-
pageUrl:
|
|
641
|
+
pageUrl: currentPageUrl,
|
|
568
642
|
pageSearch: "",
|
|
569
643
|
pageReferrer: "",
|
|
570
644
|
browser: common.browser,
|
|
@@ -631,7 +705,7 @@ internal class AnalyticsClient {
|
|
|
631
705
|
newUser: sessionManager.isNewUser,
|
|
632
706
|
pageId: sessionManager.getOrCreatePageId(),
|
|
633
707
|
sessionDepth: sessionManager.sessionDepth,
|
|
634
|
-
pageUrl:
|
|
708
|
+
pageUrl: currentPageUrl,
|
|
635
709
|
pageSearch: "",
|
|
636
710
|
pageReferrer: "",
|
|
637
711
|
browser: common.browser,
|
|
@@ -687,7 +761,7 @@ internal class AnalyticsClient {
|
|
|
687
761
|
newUser: sessionManager.isNewUser,
|
|
688
762
|
pageId: sessionManager.getOrCreatePageId(),
|
|
689
763
|
sessionDepth: sessionManager.sessionDepth,
|
|
690
|
-
pageUrl:
|
|
764
|
+
pageUrl: currentPageUrl,
|
|
691
765
|
pageSearch: "",
|
|
692
766
|
pageReferrer: "",
|
|
693
767
|
browser: common.browser,
|
|
@@ -708,7 +782,7 @@ internal class AnalyticsClient {
|
|
|
708
782
|
diiSource: "",
|
|
709
783
|
gamNetworkCode: gamNetworkCode,
|
|
710
784
|
amznPubId: AnalyticsClient.nilUUID,
|
|
711
|
-
customDimensions: [:], //
|
|
785
|
+
customDimensions: [:], // Engagement uses array-valued custom dimensions per backend schema
|
|
712
786
|
engagedTime: engagedTime,
|
|
713
787
|
timeOnPage: timeOnPage,
|
|
714
788
|
scrollDepth: scrollDepth
|
|
@@ -761,6 +835,8 @@ internal class AnalyticsClient {
|
|
|
761
835
|
|
|
762
836
|
/**
|
|
763
837
|
* Flush all batched impression events
|
|
838
|
+
*
|
|
839
|
+
* Merges events with the same pageId into a single object with combined impressions array.
|
|
764
840
|
*/
|
|
765
841
|
private func flushImpressionBatch() {
|
|
766
842
|
impressionBatchLock.lock()
|
|
@@ -770,28 +846,40 @@ internal class AnalyticsClient {
|
|
|
770
846
|
impressionBatchTimer = nil
|
|
771
847
|
impressionBatchLock.unlock()
|
|
772
848
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
849
|
+
if events.isEmpty { return }
|
|
850
|
+
|
|
851
|
+
// Group by pageId and merge impressions into single objects
|
|
852
|
+
var merged: [String: ImpressionBatchEvent] = [:]
|
|
853
|
+
for event in events {
|
|
854
|
+
if var existing = merged[event.pageId] {
|
|
855
|
+
existing.impressions.append(contentsOf: event.impressions)
|
|
856
|
+
merged[event.pageId] = existing
|
|
857
|
+
} else {
|
|
858
|
+
merged[event.pageId] = event
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
let mergedEvents = Array(merged.values)
|
|
863
|
+
|
|
864
|
+
Task {
|
|
865
|
+
do {
|
|
866
|
+
let encoder = JSONEncoder()
|
|
867
|
+
let data = try encoder.encode(mergedEvents)
|
|
868
|
+
let json = String(data: data, encoding: .utf8)!
|
|
869
|
+
let url = "\(baseURL)/impressions"
|
|
870
|
+
|
|
871
|
+
BCLogger.debug("Sending \(mergedEvents.count) batched impression groups (\(events.count) total impressions)")
|
|
872
|
+
|
|
873
|
+
let result = await httpClient.post(url: url, body: json)
|
|
874
|
+
|
|
875
|
+
switch result {
|
|
876
|
+
case .success:
|
|
877
|
+
BCLogger.verbose("Impression batch sent successfully")
|
|
878
|
+
case .failure(let error):
|
|
879
|
+
BCLogger.warning("Failed to send impression batch: \(error)")
|
|
794
880
|
}
|
|
881
|
+
} catch {
|
|
882
|
+
BCLogger.error("Error sending impression batch: \(error)")
|
|
795
883
|
}
|
|
796
884
|
}
|
|
797
885
|
}
|
|
@@ -817,6 +905,8 @@ internal class AnalyticsClient {
|
|
|
817
905
|
|
|
818
906
|
/**
|
|
819
907
|
* Flush all batched viewability events
|
|
908
|
+
*
|
|
909
|
+
* Merges events with the same pageId into a single object with combined viewability array.
|
|
820
910
|
*/
|
|
821
911
|
private func flushViewabilityBatch() {
|
|
822
912
|
viewabilityBatchLock.lock()
|
|
@@ -826,28 +916,40 @@ internal class AnalyticsClient {
|
|
|
826
916
|
viewabilityBatchTimer = nil
|
|
827
917
|
viewabilityBatchLock.unlock()
|
|
828
918
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
919
|
+
if events.isEmpty { return }
|
|
920
|
+
|
|
921
|
+
// Group by pageId and merge viewability into single objects
|
|
922
|
+
var merged: [String: ViewabilityEvent] = [:]
|
|
923
|
+
for event in events {
|
|
924
|
+
if var existing = merged[event.pageId] {
|
|
925
|
+
existing.viewability.append(contentsOf: event.viewability)
|
|
926
|
+
merged[event.pageId] = existing
|
|
927
|
+
} else {
|
|
928
|
+
merged[event.pageId] = event
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
let mergedEvents = Array(merged.values)
|
|
933
|
+
|
|
934
|
+
Task {
|
|
935
|
+
do {
|
|
936
|
+
let encoder = JSONEncoder()
|
|
937
|
+
let data = try encoder.encode(mergedEvents)
|
|
938
|
+
let json = String(data: data, encoding: .utf8)!
|
|
939
|
+
let url = "\(baseURL)/viewability"
|
|
940
|
+
|
|
941
|
+
BCLogger.debug("Sending \(mergedEvents.count) batched viewability groups (\(events.count) total)")
|
|
942
|
+
|
|
943
|
+
let result = await httpClient.post(url: url, body: json)
|
|
944
|
+
|
|
945
|
+
switch result {
|
|
946
|
+
case .success:
|
|
947
|
+
BCLogger.verbose("Viewability batch sent successfully")
|
|
948
|
+
case .failure(let error):
|
|
949
|
+
BCLogger.warning("Failed to send viewability batch: \(error)")
|
|
850
950
|
}
|
|
951
|
+
} catch {
|
|
952
|
+
BCLogger.error("Error sending viewability batch: \(error)")
|
|
851
953
|
}
|
|
852
954
|
}
|
|
853
955
|
}
|
|
@@ -10,8 +10,8 @@ import Foundation
|
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
12
|
* ```swift
|
|
13
|
-
* let
|
|
14
|
-
* //
|
|
13
|
+
* let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
|
|
14
|
+
* // bidResponse contains targeting KVPs and auction metadata
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
internal class BidRequestClient {
|
|
@@ -29,9 +29,15 @@ internal class BidRequestClient {
|
|
|
29
29
|
|
|
30
30
|
// MARK: - Types
|
|
31
31
|
|
|
32
|
+
/// Bid response containing both targeting KVPs and auction metadata
|
|
33
|
+
internal struct BidResponse {
|
|
34
|
+
let targeting: [String: String]
|
|
35
|
+
let auctionData: AuctionData
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
private struct PendingRequest {
|
|
33
39
|
let placement: PlacementConfig
|
|
34
|
-
let continuation: CheckedContinuation<
|
|
40
|
+
let continuation: CheckedContinuation<BidResponse?, Never>
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
// MARK: - Init
|
|
@@ -59,7 +65,7 @@ internal class BidRequestClient {
|
|
|
59
65
|
* - Parameter placement: The placement to fetch demand for
|
|
60
66
|
* - Returns: Targeting KVPs to apply to GAM request, or nil
|
|
61
67
|
*/
|
|
62
|
-
func fetchDemand(placement: PlacementConfig) async ->
|
|
68
|
+
func fetchDemand(placement: PlacementConfig) async -> BidResponse? {
|
|
63
69
|
guard s2sConfig.enabled else {
|
|
64
70
|
BCLogger.debug("BidRequestClient: S2S is disabled, skipping bid request")
|
|
65
71
|
return nil
|
|
@@ -104,15 +110,15 @@ internal class BidRequestClient {
|
|
|
104
110
|
|
|
105
111
|
// Distribute results to each pending continuation
|
|
106
112
|
for pending in batch {
|
|
107
|
-
let
|
|
108
|
-
pending.continuation.resume(returning:
|
|
113
|
+
let bidResponse = results[pending.placement.placementId]
|
|
114
|
+
pending.continuation.resume(returning: bidResponse)
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
// MARK: - Request Building & Execution
|
|
114
120
|
|
|
115
|
-
private func executeBidRequest(placements: [PlacementConfig]) async -> [String:
|
|
121
|
+
private func executeBidRequest(placements: [PlacementConfig]) async -> [String: BidResponse] {
|
|
116
122
|
let requestBody = buildRequestJSON(placements: placements)
|
|
117
123
|
|
|
118
124
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody),
|
|
@@ -131,7 +137,7 @@ internal class BidRequestClient {
|
|
|
131
137
|
|
|
132
138
|
switch result {
|
|
133
139
|
case .success(let responseJson):
|
|
134
|
-
return parseResponse(responseJson)
|
|
140
|
+
return parseResponse(responseJson, placements: placements)
|
|
135
141
|
case .failure(let error):
|
|
136
142
|
BCLogger.error("BidRequestClient: Bid request failed: \(error)")
|
|
137
143
|
return [:]
|
|
@@ -310,7 +316,7 @@ internal class BidRequestClient {
|
|
|
310
316
|
*
|
|
311
317
|
* Groups bids by impid, picks winner (highest price), copies ext.targeting.
|
|
312
318
|
*/
|
|
313
|
-
private func parseResponse(_ jsonString: String) -> [String:
|
|
319
|
+
private func parseResponse(_ jsonString: String, placements: [PlacementConfig]) -> [String: BidResponse] {
|
|
314
320
|
guard let data = jsonString.data(using: .utf8),
|
|
315
321
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
316
322
|
let seatbids = json["seatbid"] as? [[String: Any]] else {
|
|
@@ -318,10 +324,23 @@ internal class BidRequestClient {
|
|
|
318
324
|
return [:]
|
|
319
325
|
}
|
|
320
326
|
|
|
327
|
+
let auctionId = json["id"] as? String
|
|
328
|
+
|
|
329
|
+
// Build floor price lookup from placement configs
|
|
330
|
+
let floorPrices = Dictionary(uniqueKeysWithValues: placements.map { ($0.placementId, $0.floorPrice) })
|
|
331
|
+
|
|
321
332
|
// Collect all bids grouped by impid
|
|
322
|
-
|
|
333
|
+
struct BidInfo {
|
|
334
|
+
let price: Double
|
|
335
|
+
let targeting: [String: String]
|
|
336
|
+
let seat: String
|
|
337
|
+
let creativeId: String?
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
var bidsByImpId: [String: [BidInfo]] = [:]
|
|
323
341
|
|
|
324
342
|
for seatbid in seatbids {
|
|
343
|
+
let seat = seatbid["seat"] as? String ?? ""
|
|
325
344
|
guard let bids = seatbid["bid"] as? [[String: Any]] else { continue }
|
|
326
345
|
for bid in bids {
|
|
327
346
|
guard let impid = bid["impid"] as? String,
|
|
@@ -330,17 +349,35 @@ internal class BidRequestClient {
|
|
|
330
349
|
let targeting = ext["targeting"] as? [String: String] else {
|
|
331
350
|
continue
|
|
332
351
|
}
|
|
333
|
-
|
|
352
|
+
let crid = bid["crid"] as? String
|
|
353
|
+
bidsByImpId[impid, default: []].append(BidInfo(
|
|
354
|
+
price: price, targeting: targeting, seat: seat, creativeId: crid
|
|
355
|
+
))
|
|
334
356
|
}
|
|
335
357
|
}
|
|
336
358
|
|
|
337
|
-
// Pick winner (highest price) for each impid
|
|
338
|
-
var results: [String:
|
|
359
|
+
// Pick winner (highest price) for each impid and compute auction metadata
|
|
360
|
+
var results: [String: BidResponse] = [:]
|
|
339
361
|
for (impid, bids) in bidsByImpId {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
362
|
+
let sortedBids = bids.sorted { $0.price > $1.price }
|
|
363
|
+
guard let winner = sortedBids.first else { continue }
|
|
364
|
+
|
|
365
|
+
let secondHighestBid = sortedBids.count > 1 ? sortedBids[1].price : nil
|
|
366
|
+
let floorPrice = floorPrices[impid] ?? nil
|
|
367
|
+
|
|
368
|
+
let auctionData = AuctionData(
|
|
369
|
+
auctionId: auctionId,
|
|
370
|
+
bidder: winner.seat,
|
|
371
|
+
bidPriceCpm: winner.price,
|
|
372
|
+
creativeId: winner.creativeId,
|
|
373
|
+
gamPriceBucket: winner.targeting["hb_pb"],
|
|
374
|
+
floorPrice: floorPrice,
|
|
375
|
+
secondHighestBid: secondHighestBid,
|
|
376
|
+
demandChannel: "S2S"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
results[impid] = BidResponse(targeting: winner.targeting, auctionData: auctionData)
|
|
380
|
+
BCLogger.debug("BidRequestClient: Winner for \(impid): $\(winner.price)")
|
|
344
381
|
}
|
|
345
382
|
|
|
346
383
|
BCLogger.info("BidRequestClient: Parsed \(results.count) winning bids")
|