diff --git a/Amplitude/AMPDatabaseHelper.m b/Amplitude/AMPDatabaseHelper.m index 76c1c6f8..f2dc80da 100644 --- a/Amplitude/AMPDatabaseHelper.m +++ b/Amplitude/AMPDatabaseHelper.m @@ -386,16 +386,9 @@ - (NSMutableArray*)getEventsFromTable:(NSString*) table upToId:(long long) upToI continue; } NSString *eventString = [NSString stringWithUTF8String:rawEventString]; - if ([AMPUtils isEmptyString:eventString]) { - AMPLITUDE_LOG(@"Ignoring empty event string for event id %lld from table %@", eventId, table); - continue; - } - - NSData *eventData = [eventString dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error = nil; - id eventImmutable = [NSJSONSerialization JSONObjectWithData:eventData options:0 error:&error]; - if (error != nil) { - AMPLITUDE_LOG(@"Error JSON deserialization of event id %lld from table %@: %@", eventId, table, error); + NSDictionary *eventImmutable = [AMPUtils deserializeEventString:eventString]; + if (eventImmutable == nil) { + AMPLITUDE_LOG(@"Failed to deserialize event from table %@", table); continue; } diff --git a/Amplitude/AMPUtils.h b/Amplitude/AMPUtils.h index a80d8ff9..ce7cafd0 100644 --- a/Amplitude/AMPUtils.h +++ b/Amplitude/AMPUtils.h @@ -12,5 +12,6 @@ + (id) makeJSONSerializable:(id) obj; + (BOOL) isEmptyString:(NSString*) str; + (NSDictionary*) validateGroups:(NSDictionary*) obj; ++ (NSDictionary*) deserializeEventString:(NSString*) eventString; @end diff --git a/Amplitude/AMPUtils.m b/Amplitude/AMPUtils.m index 45888039..220717c9 100644 --- a/Amplitude/AMPUtils.m +++ b/Amplitude/AMPUtils.m @@ -141,4 +141,20 @@ + (NSDictionary *) validateGroups:(NSDictionary *) obj return [NSDictionary dictionaryWithDictionary:dict]; } ++ (NSDictionary *) deserializeEventString:(NSString *)eventString +{ + if ([self isEmptyString:eventString]) { + return nil; + } + + NSData *eventData = [eventString dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id event = [NSJSONSerialization JSONObjectWithData:eventData options:0 error:&error]; + if (error != nil) { + AMPLITUDE_LOG(@"Error JSON deserialization of event: %@", error); + return nil; + } + return event; +} + @end diff --git a/Amplitude/Amplitude.m b/Amplitude/Amplitude.m index 18f5b944..e27db67e 100644 --- a/Amplitude/Amplitude.m +++ b/Amplitude/Amplitude.m @@ -628,6 +628,8 @@ - (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) event } if ([eventType isEqualToString:IDENTIFY_EVENT]) { (void) [self.dbHelper addIdentify:jsonString]; + } else if ([eventType isEqualToString:kAMPSessionEndEvent]) { + (void) [self.dbHelper insertOrReplaceKeyValue:kAMPSessionEndEvent value:jsonString]; } else { (void) [self.dbHelper addEvent:jsonString]; } @@ -1102,6 +1104,7 @@ - (void)enterBackground [self runOnBackgroundQueue:^{ _inForeground = NO; [self refreshSessionTime:now]; + [self sendSessionEvent:kAMPSessionEndEvent timestamp:now]; [self uploadEventsWithLimit:0]; }]; } @@ -1151,29 +1154,45 @@ - (BOOL)startOrContinueSession:(NSNumber*) timestamp - (void)startNewSession:(NSNumber*) timestamp { if (_trackingSessionEvents) { - [self sendSessionEvent:kAMPSessionEndEvent]; + + // try to load saved end session event from key value table, else fall back to re-logging + NSNumber *lastEventTime = [self lastEventTime]; + NSString *endSessionEventString = [self.dbHelper getValue:kAMPSessionEndEvent]; + if ([AMPUtils isEmptyString:endSessionEventString]) { + [self sendSessionEvent:kAMPSessionEndEvent timestamp:lastEventTime]; + } else { + // sanity check the event + NSNumber *endSessionTimestamp = nil; + NSDictionary *endSessionEvent = [AMPUtils deserializeEventString:endSessionEventString]; + if (endSessionEvent != nil) { + endSessionTimestamp = [endSessionEvent objectForKey:@"timestamp"]; + } + if (endSessionEvent == nil || endSessionTimestamp == nil || [endSessionTimestamp longLongValue] != [lastEventTime longLongValue]) { + [self sendSessionEvent:kAMPSessionEndEvent timestamp:lastEventTime]; + } + } + + // transfer end session event from key value table into the event queue + endSessionEventString = [self.dbHelper getValue:kAMPSessionEndEvent]; + if (![AMPUtils isEmptyString:endSessionEventString]) { + [self.dbHelper addEvent:endSessionEventString]; + [self.dbHelper insertOrReplaceKeyValue:kAMPSessionEndEvent value:nil]; + } } [self setSessionId:[timestamp longLongValue]]; [self refreshSessionTime:timestamp]; if (_trackingSessionEvents) { - [self sendSessionEvent:kAMPSessionStartEvent]; + [self sendSessionEvent:kAMPSessionStartEvent timestamp:timestamp]; } } -- (void)sendSessionEvent:(NSString*) sessionEvent +- (void)sendSessionEvent:(NSString*) sessionEvent timestamp:(NSNumber *) timestamp { - if (_apiKey == nil) { - AMPLITUDE_ERROR(@"ERROR: apiKey cannot be nil or empty, set apiKey with initializeApiKey: before sending session event"); - return; - } - - if (![self inSession]) { + if (_apiKey == nil || ![self inSession]) { return; } - NSMutableDictionary *apiProperties = [NSMutableDictionary dictionary]; - [apiProperties setValue:sessionEvent forKey:@"special"]; - NSNumber* timestamp = [self lastEventTime]; + NSDictionary *apiProperties = [NSDictionary dictionaryWithObject:sessionEvent forKey:@"special"]; [self logEvent:sessionEvent withEventProperties:nil withApiProperties:apiProperties withUserProperties:nil withGroups:nil withTimestamp:timestamp outOfSession:NO]; } diff --git a/AmplitudeTests/SessionTests.m b/AmplitudeTests/SessionTests.m index 45863106..cf3def87 100644 --- a/AmplitudeTests/SessionTests.m +++ b/AmplitudeTests/SessionTests.m @@ -17,6 +17,7 @@ #import "Amplitude.h" #import "Amplitude+Test.h" #import "BaseTestCase.h" +#import "AMPUtils.h" @interface SessionTests : BaseTestCase @@ -150,6 +151,8 @@ - (void)testEnterBackgroundDoesNotTrackEvent { } - (void)testTrackSessionEvents { + AMPDatabaseHelper *dbHelper = [AMPDatabaseHelper getDatabaseHelper]; + id mockAmplitude = [OCMockObject partialMockForObject:self.amplitude]; NSDate *date = [NSDate dateWithTimeIntervalSince1970:1000]; [[[mockAmplitude expect] andReturnValue:OCMOCK_VALUE(date)] currentTime]; @@ -201,6 +204,83 @@ - (void)testTrackSessionEvents { [mockAmplitude identify:identify outOfSession:YES]; [mockAmplitude flushQueue]; XCTAssertEqual([mockAmplitude queuedEventCount], 5); // does not trigger session events + + // test new end session logic -> go to background + NSDate *date5 = [NSDate dateWithTimeIntervalSince1970:1000 + 4 * self.amplitude.minTimeBetweenSessionsMillis]; + [[[mockAmplitude expect] andReturnValue:OCMOCK_VALUE(date5)] currentTime]; + [mockAmplitude enterBackground]; // simulate app entering background + [mockAmplitude flushQueue]; + XCTAssertEqual([mockAmplitude queuedEventCount], 5); // no actual events logged + + // verify end session event is added to key value table + NSString *endSessionEventString = [dbHelper getValue:kAMPSessionEndEvent]; + XCTAssertFalse([AMPUtils isEmptyString:endSessionEventString]); + NSDictionary *endSessionEvent = [AMPUtils deserializeEventString:endSessionEventString]; + XCTAssertNotNil(endSessionEvent); + NSNumber *endSessionTimestamp = [endSessionEvent objectForKey:@"timestamp"]; + NSNumber *expectedTimestamp = [NSNumber numberWithLongLong:[date5 timeIntervalSince1970] * 1000]; + XCTAssertEqualObjects(expectedTimestamp, endSessionTimestamp); + + // verify that the end session event sent is loaded from key value table + // we can modify the value in the database and verify the modified event is the one that is sent + // adding a value for version_name + endSessionEventString = @"{\"uuid\":\"CC83932C-7520-4AAD-BC95-8E59D30057D6\",\"device_manufacturer\":\"Apple\",\"library\":{\"name\":\"amplitude-ios\",\"version\":\"3.11.1\"},\"event_type\":\"session_end\",\"os_name\":\"ios\",\"sequence_number\":10,\"timestamp\":1201000000,\"event_properties\":{},\"api_properties\":{\"special\":\"session_end\",\"ios_idfv\":\"AFD750D7-1A23-44AB-A091-673A6229647A\"},\"groups\":{},\"user_properties\":{},\"platform\":\"iOS\",\"language\":\"English\",\"device_id\":\"AFD750D7-1A23-44AB-A091-673A6229647A\",\"os_version\":\"10.1\",\"session_id\":601000000,\"device_model\":\"Simulator\",\"country\":\"United States\",\"version_name\":\"test_version\"}"; + [dbHelper insertOrReplaceKeyValue:kAMPSessionEndEvent value:endSessionEventString]; + + // force app to foreground and verify new session logic + NSDate *date6 = [NSDate dateWithTimeIntervalSince1970:1000 + 5 * self.amplitude.minTimeBetweenSessionsMillis]; + [[[mockAmplitude expect] andReturnValue:OCMOCK_VALUE(date6)] currentTime]; + [mockAmplitude enterForeground]; + [mockAmplitude flushQueue]; + XCTAssertEqual([mockAmplitude queuedEventCount], 7); // end and start session events logged + + NSArray *events = [dbHelper getEvents:-1 limit:-1]; + XCTAssertEqual([events count], 7); + endSessionEvent = [events objectAtIndex:5]; + XCTAssertEqualObjects([endSessionEvent objectForKey:@"event_type"], kAMPSessionEndEvent); + XCTAssertEqualObjects([endSessionEvent objectForKey:@"timestamp"], expectedTimestamp); + XCTAssertEqualObjects([endSessionEvent objectForKey:@"version_name"], @"test_version"); + XCTAssertNil([dbHelper getValue:kAMPSessionEndEvent]); + + // the start session event should be missing the version_name + expectedTimestamp = [NSNumber numberWithLongLong:[date6 timeIntervalSince1970] * 1000]; + NSDictionary *startSessionEvent = [events objectAtIndex:6]; + XCTAssertEqualObjects([startSessionEvent objectForKey:@"event_type"], kAMPSessionStartEvent); + XCTAssertEqualObjects([startSessionEvent objectForKey:@"timestamp"], expectedTimestamp); + XCTAssertNil([startSessionEvent objectForKey:@"version_name"]); + + // test new session logic -> verify saved end session event is discarded if the timestamp does not match + // the start session event logged at date6 should have updated the lastEventTime + [[[mockAmplitude expect] andReturnValue:OCMOCK_VALUE(date6)] currentTime]; + [mockAmplitude enterBackground]; // simulate app entering background + [mockAmplitude flushQueue]; + XCTAssertEqual([mockAmplitude queuedEventCount], 7); // no actual events logged + + // if we try to re-send our modified endSessionEventString with date5, it should discard it and log a new one + endSessionEventString = @"{\"uuid\":\"CC83932C-7520-4AAD-BC95-8E59D30057D6\",\"device_manufacturer\":\"Apple\",\"library\":{\"name\":\"amplitude-ios\",\"version\":\"3.11.1\"},\"event_type\":\"session_end\",\"os_name\":\"ios\",\"sequence_number\":10,\"timestamp\":1201000000,\"event_properties\":{},\"api_properties\":{\"special\":\"session_end\",\"ios_idfv\":\"AFD750D7-1A23-44AB-A091-673A6229647A\"},\"groups\":{},\"user_properties\":{},\"platform\":\"iOS\",\"language\":\"English\",\"device_id\":\"AFD750D7-1A23-44AB-A091-673A6229647A\",\"os_version\":\"10.1\",\"session_id\":601000000,\"device_model\":\"Simulator\",\"country\":\"United States\",\"version_name\":\"test_version\"}"; + [dbHelper insertOrReplaceKeyValue:kAMPSessionEndEvent value:endSessionEventString]; + + // force app to foreground and verify new session logic + NSDate *date7 = [NSDate dateWithTimeIntervalSince1970:1000 + 6 * self.amplitude.minTimeBetweenSessionsMillis]; + [[[mockAmplitude expect] andReturnValue:OCMOCK_VALUE(date7)] currentTime]; + [mockAmplitude enterForeground]; + [mockAmplitude flushQueue]; + XCTAssertEqual([mockAmplitude queuedEventCount], 9); // end and start session events logged + + events = [dbHelper getEvents:-1 limit:-1]; + XCTAssertEqual([events count], 9); + endSessionEvent = [events objectAtIndex:7]; + XCTAssertEqualObjects([endSessionEvent objectForKey:@"event_type"], kAMPSessionEndEvent); + XCTAssertEqualObjects([endSessionEvent objectForKey:@"timestamp"], expectedTimestamp); + XCTAssertNil([endSessionEvent objectForKey:@"version_name"]); // should be missing the version_name since a brand new end session event is logged + XCTAssertNil([dbHelper getValue:kAMPSessionEndEvent]); + + // the start session event should be missing the version_name + startSessionEvent = [events objectAtIndex:8]; + XCTAssertEqualObjects([startSessionEvent objectForKey:@"event_type"], kAMPSessionStartEvent); + XCTAssertEqualObjects([startSessionEvent objectForKey:@"timestamp"], [NSNumber numberWithLongLong:[date7 timeIntervalSince1970] * 1000]); + XCTAssertNil([startSessionEvent objectForKey:@"version_name"]); + } - (void)testSessionEventsOn32BitDevices {