BTAnalyticsService.m 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. #import "BTAnalyticsMetadata.h"
  2. #import "BTAnalyticsService.h"
  3. #import "BTAPIClient_Internal.h"
  4. #import "BTClientMetadata.h"
  5. #import "BTHTTP.h"
  6. #import "BTLogger_Internal.h"
  7. #import <UIKit/UIKit.h>
  8. #pragma mark - BTAnalyticsEvent
  9. /// Encapsulates a single analytics event
  10. @interface BTAnalyticsEvent : NSObject
  11. @property (nonatomic, copy) NSString *kind;
  12. @property (nonatomic, assign) uint64_t timestamp;
  13. + (nonnull instancetype)event:(nonnull NSString *)eventKind withTimestamp:(uint64_t)timestamp;
  14. /// Event serialized to JSON
  15. - (nonnull NSDictionary *)json;
  16. @end
  17. @implementation BTAnalyticsEvent
  18. + (instancetype)event:(NSString *)eventKind withTimestamp:(uint64_t)timestamp {
  19. BTAnalyticsEvent *event = [[BTAnalyticsEvent alloc] init];
  20. event.kind = eventKind;
  21. event.timestamp = timestamp;
  22. return event;
  23. }
  24. - (NSString *)description {
  25. return [NSString stringWithFormat:@"%@ at %llu", self.kind, (uint64_t)self.timestamp];
  26. }
  27. - (NSDictionary *)json {
  28. return @{
  29. @"kind": self.kind,
  30. @"timestamp": @(self.timestamp)
  31. };
  32. }
  33. @end
  34. #pragma mark - BTAnalyticsSession
  35. /// Encapsulates analytics events for a given session
  36. @interface BTAnalyticsSession : NSObject
  37. @property (nonatomic, copy, nonnull) NSString *sessionID;
  38. @property (nonatomic, copy, nonnull) NSString *source;
  39. @property (nonatomic, copy, nonnull) NSString *integration;
  40. @property (nonatomic, strong, nonnull) NSMutableArray <BTAnalyticsEvent *> *events;
  41. /// Dictionary of analytics metadata from `BTAnalyticsMetadata`
  42. @property (nonatomic, strong, nonnull) NSDictionary *metadataParameters;
  43. + (nonnull instancetype)sessionWithID:(nonnull NSString *)sessionID
  44. source:(nonnull NSString *)source
  45. integration:(nonnull NSString *)integration;
  46. @end
  47. @implementation BTAnalyticsSession
  48. - (instancetype)init {
  49. if (self = [super init]) {
  50. _events = [NSMutableArray array];
  51. _metadataParameters = [BTAnalyticsMetadata metadata];
  52. }
  53. return self;
  54. }
  55. + (instancetype)sessionWithID:(NSString *)sessionID
  56. source:(NSString *)source
  57. integration:(NSString *)integration
  58. {
  59. if (!sessionID || !source || !integration) {
  60. return nil;
  61. }
  62. BTAnalyticsSession *session = [[BTAnalyticsSession alloc] init];
  63. session.sessionID = sessionID;
  64. session.source = source;
  65. session.integration = integration;
  66. return session;
  67. }
  68. @end
  69. #pragma mark - BTAnalyticsService
  70. @interface BTAnalyticsService ()
  71. /// Dictionary of analytics sessions, keyed by session ID. The analytics service requires that batched events
  72. /// are sent from only one session. In practice, BTAPIClient.metadata.sessionId should never change, so this
  73. /// is defensive.
  74. @property (nonatomic, strong) NSMutableDictionary <NSString *, BTAnalyticsSession *> *analyticsSessions;
  75. /// A serial dispatch queue that synchronizes access to `analyticsSessions`
  76. @property (nonatomic, strong) dispatch_queue_t sessionsQueue;
  77. @end
  78. @implementation BTAnalyticsService
  79. NSString * const BTAnalyticsServiceErrorDomain = @"com.braintreepayments.BTAnalyticsServiceErrorDomain";
  80. - (instancetype)initWithAPIClient:(BTAPIClient *)apiClient {
  81. if (self = [super init]) {
  82. _analyticsSessions = [NSMutableDictionary dictionary];
  83. _sessionsQueue = dispatch_queue_create("com.braintreepayments.BTAnalyticsService", DISPATCH_QUEUE_SERIAL);
  84. _apiClient = apiClient;
  85. _flushThreshold = 1;
  86. [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillResign:) name:UIApplicationWillResignActiveNotification object:nil];
  87. }
  88. return self;
  89. }
  90. - (void)dealloc {
  91. [[NSNotificationCenter defaultCenter] removeObserver:self];
  92. }
  93. #pragma mark - Public methods
  94. - (void)sendAnalyticsEvent:(NSString *)eventKind {
  95. dispatch_async(dispatch_get_main_queue(), ^{
  96. [self enqueueEvent:eventKind];
  97. [self checkFlushThreshold];
  98. });
  99. }
  100. - (void)sendAnalyticsEvent:(NSString *)eventKind completion:(__unused void(^)(NSError *error))completionBlock {
  101. dispatch_async(dispatch_get_main_queue(), ^{
  102. [self enqueueEvent:eventKind];
  103. [self flush:completionBlock];
  104. });
  105. }
  106. - (void)flush:(void (^)(NSError *))completionBlock {
  107. [self.apiClient fetchOrReturnRemoteConfiguration:^(BTConfiguration *configuration, NSError *error) {
  108. if (error) {
  109. [[BTLogger sharedLogger] warning:[NSString stringWithFormat:@"Failed to send analytics event. Remote configuration fetch failed. %@", error.localizedDescription]];
  110. if (completionBlock) completionBlock(error);
  111. return;
  112. }
  113. NSURL *analyticsURL = [configuration.json[@"analytics"][@"url"] asURL];
  114. if (!analyticsURL) {
  115. [[BTLogger sharedLogger] debug:@"Skipping sending analytics event - analytics is disabled in remote configuration"];
  116. NSError *error = [NSError errorWithDomain:BTAnalyticsServiceErrorDomain code:BTAnalyticsServiceErrorTypeMissingAnalyticsURL userInfo:@{ NSLocalizedDescriptionKey : @"Analytics is disabled in remote configuration" }];
  117. if (completionBlock) completionBlock(error);
  118. return;
  119. }
  120. if (!self.http) {
  121. if (self.apiClient.clientToken) {
  122. self.http = [[BTHTTP alloc] initWithBaseURL:analyticsURL authorizationFingerprint:self.apiClient.clientToken.authorizationFingerprint];
  123. } else if (self.apiClient.tokenizationKey) {
  124. self.http = [[BTHTTP alloc] initWithBaseURL:analyticsURL tokenizationKey:self.apiClient.tokenizationKey];
  125. } else if (self.apiClient.payPalIDToken) {
  126. self.http = [[BTHTTP alloc] initWithBaseURL:analyticsURL authorizationFingerprint:self.apiClient.payPalIDToken.token];
  127. return;
  128. }
  129. if (!self.http) {
  130. NSError *error = [NSError errorWithDomain:BTAnalyticsServiceErrorDomain code:BTAnalyticsServiceErrorTypeInvalidAPIClient userInfo:@{ NSLocalizedDescriptionKey : @"API client must have client token or tokenization key" }];
  131. [[BTLogger sharedLogger] warning:error.localizedDescription];
  132. if (completionBlock) completionBlock(error);
  133. return;
  134. }
  135. }
  136. // A special value passed in by unit tests to prevent BTHTTP from actually posting
  137. if ([self.http.baseURL isEqual:[NSURL URLWithString:@"test://do-not-send.url"]]) {
  138. if (completionBlock) completionBlock(nil);
  139. return;
  140. }
  141. dispatch_async(self.sessionsQueue, ^{
  142. if (self.analyticsSessions.count == 0) {
  143. if (completionBlock) completionBlock(nil);
  144. return;
  145. }
  146. BOOL willPostAnalyticsEvent = NO;
  147. for (NSString *sessionID in self.analyticsSessions.allKeys) {
  148. BTAnalyticsSession *session = self.analyticsSessions[sessionID];
  149. if (session.events.count == 0) {
  150. continue;
  151. }
  152. willPostAnalyticsEvent = YES;
  153. NSMutableDictionary *metadataParameters = [NSMutableDictionary dictionary];
  154. [metadataParameters addEntriesFromDictionary:session.metadataParameters];
  155. metadataParameters[@"sessionId"] = session.sessionID;
  156. metadataParameters[@"integrationType"] = session.integration;
  157. metadataParameters[@"source"] = session.source;
  158. NSMutableDictionary *postParameters = [NSMutableDictionary dictionary];
  159. if (session.events) {
  160. // Map array of BTAnalyticsEvent to JSON
  161. postParameters[@"analytics"] = [session.events valueForKey:@"json"];
  162. }
  163. postParameters[@"_meta"] = metadataParameters;
  164. if (self.apiClient.clientToken.authorizationFingerprint) {
  165. postParameters[@"authorization_fingerprint"] = self.apiClient.clientToken.authorizationFingerprint;
  166. }
  167. if (self.apiClient.tokenizationKey) {
  168. postParameters[@"tokenization_key"] = self.apiClient.tokenizationKey;
  169. }
  170. [session.events removeAllObjects];
  171. [self.http POST:@"/" parameters:postParameters completion:^(__unused BTJSON *body, __unused NSHTTPURLResponse *response, NSError *error) {
  172. if (error != nil) {
  173. [[BTLogger sharedLogger] warning:@"Failed to flush analytics events: %@", error.localizedDescription];
  174. }
  175. if (completionBlock) completionBlock(error);
  176. }];
  177. }
  178. if (!willPostAnalyticsEvent && completionBlock) {
  179. completionBlock(nil);
  180. }
  181. });
  182. }];
  183. }
  184. #pragma mark - Private methods
  185. - (void)appWillResign:(NSNotification *)notification {
  186. UIApplication *application = notification.object;
  187. __block UIBackgroundTaskIdentifier bgTask;
  188. bgTask = [application beginBackgroundTaskWithName:@"BTAnalyticsService" expirationHandler:^{
  189. [[BTLogger sharedLogger] warning:@"Analytics service background task expired"];
  190. [application endBackgroundTask:bgTask];
  191. bgTask = UIBackgroundTaskInvalid;
  192. }];
  193. // Start the long-running task and return immediately.
  194. dispatch_async(self.sessionsQueue, ^{
  195. [self flush:^(__unused NSError * _Nullable error) {
  196. [application endBackgroundTask:bgTask];
  197. bgTask = UIBackgroundTaskInvalid;
  198. }];
  199. });
  200. }
  201. #pragma mark - Helpers
  202. - (void)enqueueEvent:(NSString *)eventKind {
  203. uint64_t timestampInMilliseconds = ([[NSDate date] timeIntervalSince1970] * 1000);
  204. BTAnalyticsEvent *event = [BTAnalyticsEvent event:eventKind withTimestamp:timestampInMilliseconds];
  205. BTAnalyticsSession *session = [BTAnalyticsSession sessionWithID:self.apiClient.metadata.sessionId
  206. source:self.apiClient.metadata.sourceString
  207. integration:self.apiClient.metadata.integrationString];
  208. if (!session) {
  209. [[BTLogger sharedLogger] warning:@"Missing analytics session metadata - will not send event %@", event.kind];
  210. return;
  211. }
  212. dispatch_async(self.sessionsQueue, ^{
  213. if (!self.analyticsSessions[session.sessionID]) {
  214. self.analyticsSessions[session.sessionID] = session;
  215. }
  216. [self.analyticsSessions[session.sessionID].events addObject:event];
  217. });
  218. }
  219. - (void)checkFlushThreshold {
  220. __block NSUInteger eventCount = 0;
  221. dispatch_sync(self.sessionsQueue, ^{
  222. for (BTAnalyticsSession *analyticsSession in self.analyticsSessions.allValues) {
  223. eventCount += analyticsSession.events.count;
  224. }
  225. });
  226. if (eventCount >= self.flushThreshold) {
  227. [self flush:nil];
  228. }
  229. }
  230. @end