Данный пост открывает цикл статей о разработке аналитической системы мониторинга действий пользователей. В первой статье мы расскажем о том как собирать необходимые данные с мобильных приложений под андроид и айос.
package Birdy::Stat::Stalin;
#
# Это Сталин, он всё про всех знает
# Кто и что делает, кто и с кем спит
#
# ########################################################
# ########################################################
#
# !######### #
# !########! ##!
# !########! ###
# !########## ####
# ######### ##### ######
# !###! !####! ######
# ! ##### ######!
# !####! #######
# ##### #######
# !####! #######!
# ####!########
# ## ##########
# ,######! !#############
# ,#### ########################!####!
# ,####' ##################!' #####
# ,####' ####### !####!
# ####' #####
# ~## ##~
#
# ########################################################
# ########################################################
Мы — рекомендательный сервис Surfingbird. Чем больше мы понимаем пользователя — тем более релевантные рекомендации мы генерируем.
Google analytics, Flurry, Appsflyer — можно с головы до ног обмазаться существующими аналитическими системами. Можно построить великолепный dashboard на который вывести DAU, MAU, DNU, ARPU, K-Factor и еще с десяток показателей — но все это будут лишь тени на стенах пещеры. Ни одна система аналитики не ответит вам на вопрос ПОЧЕМУ пользователь ушел из приложения, что именно спровицировало его уход, она лишь зафиксирует факт ухода пользователя. Вы даже не сможете написать ему прощальный email) Поэтому мы решили, что для того чтобы ответить на этот и подобные вопросы — мы должны знать о пользователях все. В какой последовательности и с каким интервалом, на каких экранах и какие кнопки он нажимал. Сколько секунд и какую статью он читал, прежде чем развернуться, плюнуть и уйти. Какова гистограмма чтения статьи. Сколько времени было потрачено на каждый пиксель и в каком варианте А/Б теста. В определенный момент мы поняли что нам нужен Сталин.
Прежде всего мы согласовали структуру данных в которой хотим передавать трекаемые события. Данная структура едина для веб, мобайл и, забегая вперед — баз данных на бэкенде (да, их много).
События состоят из следующих базовых составляющих
- Action — действие пользователя, отвечает на вопрос что он сделал
- Screen — экран, отвечает на вопрос на каком экране
- ContentType — тип контента, отвечает на вопрос с каким типом контента было взаимодействие
А так же:
- userToken — кто
- time — когда
- clientVersion — в какой версии
- contentID — идентификатор контента
- deviceID — уникальный идентификатор устройства
- deviceType — тип устройства
- прочие измерения — абтесты, описания (для ошибок) и так далее
В качестве меры выступает count событий. По умолчанию он равен единице, но может использоваться для предагрегаций однотипных событий по которым нет необходимости анализа во времени.
Это базовый набор от которого мы отталкиваемся.
В свою очередь измерения могут быть представлены например неким набором значений, например:
//Действия
public enum Action {
none,
//хиты установки клики
install,//в момент запуска приложения незарегом
hit,//в момент открытия приложения
clickon_surfbutton,//клик по кнопке серф
clickon_volumebutton,//клик по кнопке громкости
//открытие лент
open_surf,//открытие ленты рекомендаций
open_feed,//открытие ленты подписок
open_popular,//открытие ленты популярного
open_dayDigest,//открытие ленты картина дня
open_profile,//открытие профилю
open_settings,//открытие настроек
open_comment,//открытие комментарияв
//блок регистрации/авторизации пользователя (начало/конец)
registrationBegin_vk,//done
registrationSignIn_vk,//done
registrationSignUp_vk,//done
registrationBegin_fb,//done
registrationSignIn_fb,//done
registrationSignUp_fb,//done
registrationBegin_email,//done
registrationComplete_email,//done
//страница
page_seen,//в андроиде пока не используется
page_click,//клик из какой нибудь ленты (8 штук)
page_open,//открытие страницы (откуда угодно)
page_read,//чтение страницы в секундах
//шаринги
share_fb,//done
share_vk,//done
share_sms,//done
share_email,//done
share_pocket,//done
share_copyLink,//done
share_saveImage,//done
share_twitter,//done
share_other,//done
//действия со страницей
like,//done
dislike,//done
favorite,//done
addToCollection,//done
//действия с пушами
openPush,//done
deliveredPush,//done
//and so on
}
Можно заметить, что в именовании измерений также зашита возможность предагрегации однотипных значений, для облегчения дальнейшего анализа в OLAP. Т.е. оставаясь плоской на уровне сбора данных — она может быть развернута в двухуровневую иерархию на уровне Куба.
Если посмотреть на модель данных, например в андроид, то любое событие может быть представлено в виде следующего класса:
public ClassEvent (Action action, Screen screen, ContentType contentType, String contentID, String abTest1, String abTest2, String description, int count) {
this.abTest1 = abTest1;
this.abTest2 = abTest2;
//and so on
this.contentType = contentType;
this.contentID = contentID;
this.time = System.currentTimeMillis()/1000;
this.deviceID = SurfingbirdApplication.getInstance().getDeviceId();
this.deviceType = "ANDROID";
String loginToken = SurfingbirdApplication.getInstance().getSettings().getLoginToken();
this.userToken = loginToken==null?"":loginToken;
this.clientVersion = SurfingbirdApplication.getInstance().getAppVersion();
}
@Override
public String toString() {
JSONObject jsonObject= new JSONObject();
try {
jsonObject.put("clientVersion", clientVersion);
jsonObject.put("action", action.toString());
jsonObject.put("screen", screen);
jsonObject.put("contentType", contentType);
jsonObject.put("contentID", contentID);
jsonObject.put("time", time);
jsonObject.put("deviceID", deviceID);
jsonObject.put("deviceType", deviceType);
jsonObject.put("userToken", userToken);
jsonObject.put("abTest1_id", abTest1);
jsonObject.put("abTest1_value", abTest2);
jsonObject.put("description", description);
jsonObject.put("count", count);
} catch (JSONException e) {
AQUtility.debug("EVENTERROR",e.toString());
}
return jsonObject.toString();
}
Как это выглядит в самом приложении?
Любое действие, на любом экране регистрируется в виде события.
Проще всего рассмотреть фрагмент моей сессии в табличном виде
Я начал сессию, кликнул на серфе, загрузил страницу 5 каких то текстовых редакторов, сколько то секунд читал ее, затем перешел на вкладку популярное, начал читать почему айфон в три раза дороже андроида. Черт, да, это же было вчера вечером, я кстати так и не понял почему)
Но не суть. Следующая задача которую необходимо решить это интеграция с другими системами аналитики (мы кстати выяснили кто врет и примерно насколько, но сейчас не об этом) и «упаковка» событий в «пачки»
На андроиде мы упаковываем в пакеты по 50 штук и, в момент генерации добавляем в гугл аналитику, для перекрестной сверки:
public void newEvent(ClassEvent.Action action,ClassEvent.Screen screen,ClassEvent.ContentType contentType,String contentId) {
registerEvent(new ClassEvent(action,screen,contentType,contentId));
}
public void newEvent(ClassEvent.Action action,ClassEvent.Screen screen,ClassEvent.ContentType contentType,String contentId,String abTest1,String abTest2,String description, int count) {
registerEvent(new ClassEvent(action,screen,contentType,contentId,abTest1,abTest2,description,count));
}
public void registerEvent(ClassEvent event) {
Tracker t = getTracker(
SurfingbirdApplication.TrackerName.GLOBAL_TRACKER);
t.setScreenName(event.screen.toString());
Map<String, String> hits = new HitBuilders.EventBuilder()
.setCategory("event")
.setAction(event.action.toString())
.setLabel(event.action.toString())
.build();
t.send(hits);
if (TextUtils.equals("",event.userToken) || TextUtils.equals("null",event.userToken)) {
String eventsString = "[";
eventsString+=event.toString();
eventsString+="]";
events.clear();
aq.ajax(UtilsApi.eventsCallBackBasic(this, "some_method", eventsString));
}
else {
events.add(event);
if (events.size()>50) {
sendEvents();
}
}
}
public void sendEvents() {
if (events.size()>0) {
String eventsString = "[";
for (ClassEvent event: events) {
if (!eventsString.equals("[")) eventsString+=",";
eventsString+=event.toString();
}
eventsString+="]";
events.clear();
aq.ajax(UtilsApi.eventsCallBack(this, "nop", eventsString));
}
}
Сильно ограниченная часть событий выполняются с базовой авторизацией и шлется сразу, все остальные упаковываются в пачки и отправляются либо по мере накопления, либо в момент завершения работы с программой — принудительно.
Так выглядит собственно «набрасывание событий на андроид»
SurfingbirdApplication.getInstance().newEvent(ClassEvent.Action.install, ClassEvent.Screen.none, ClassEvent.ContentType.none, "");
SurfingbirdApplication.getInstance().newEvent(ClassEvent.Action.openPush, ClassEvent.Screen.page_parsed, ClassEvent.ContentType.siteShort,shortUrl);
SurfingbirdApplication.getInstance().newEvent(ClassEvent.Action.registrationBegin_email, ClassEvent.Screen.start, ClassEvent.ContentType.none, "");
На айос мы опробовали немного иную логику:
События так же накапливаются в стек и при любом последующем запросе – к нему паровозом прицепляется массив накопленных сообщений. Если событий накапливается больше чем 50, мы принудительно делаем запрос с системным методом nop. Также, если отслеживаемое событие нужно отправить как можно скорее, можно форсировать nop-запрос.
// переопределяем метод у наследника AFHTTPRequestOperationManager
- (void) POST:(NSString *)path
parameters:(NSMutableDictionary *)parameters
success:(void (^__strong)(AFHTTPRequestOperation *__strong, __strong id))success
failure:(void (^__strong)(AFHTTPRequestOperation *__strong, NSError *__strong))failure {
SBEvents *events = [SBEventTracker sharedTracker].events;
if (events.count > 0) {
parameters[@"_events"] = [events jsonString];
[[SBEventTracker sharedTracker] clearEvents];
}
[super POST:path parameters:parameters success:^(AFHTTPRequestOperation *operation, id json) {
//
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
//
}];
}
На бэкенде – события приходят в модуль написанный на перл, который собственно раскладывает записи в таблицу. Но это не единственная его функция, так же он контролирует целостность данных. Если вдруг с клиента приходит событие, которое не известно Сталину — он кладет его в отдельную табличку, которая обрабатывается позднее, после устранения неконсистентности (например после добавления нового значения в соответствующий енум)
package Birdy::Stat::Stalin;
use constant {
SUCCESS => 'success',
FAILURE => 'failure',
UNKNOWN => 'unknown',
CONTENT_TYPE_NONE => 'none',
};
sub track_events {
my $params = shift;
return unless ref $params eq 'ARRAY';
return unless @$params;
my ($s_events, $f_events, $u_events) = ([],[],[]);
foreach (@$params) {
my $event = __PACKAGE__->new($_);
$event->parse;
# раскидываем события по разным спискам
given ($event->status) {
when (SUCCESS) {
push @$s_events, $event;
}
when (FAILURE) {
push @$f_events, $event;
}
when (UNKNOWN) {
push @$u_events, $event;
}
}
}
__PACKAGE__->_track_success_events($s_events);
__PACKAGE__->_track_failure_events('failure', $f_events);
__PACKAGE__->_track_failure_events('unknown', $u_events);
}
state $enums = {
'action' => [qw/
install hit
open_surf open_feed open_popular open_dayDigest open_profile open_settings open_comment
registrationBegin_email registrationComplete_email
page_seen page_click page_open
share_fb share_vk share_sms share_email share_pocket share_copyLink share_saveImage share_twitter share_other
like dislike favorite addToCollection
openPush deliveredPush openDayDigestFromLocalPush
error page_read none
/],
'screen' => [qw/
none start similar
surf feed popular dayDigest profile settings
page_parsed page_image siteTag actionBar
actionBar_profile actionBar_page actionBar_channel
profile_channel profile_add profile_like profile_favorite profile_collection
/],
'deviceType' => ['IPAD', 'IPHONE', 'ANDROID'],
'contentType' => [CONTENT_TYPE_NONE, 'siteShort', 'userShort', 'siteTag'],
};
state $fields = [ sort (keys %$enums, qw/time deviceID clientVersion userId userLogin contentID shortUrl count description/) ];
sub parse {
my ($self) = @_;
my $event_param = {};
{
my $required = [keys %$enums];
my $optional = [];
# проверим что есть нужные параметры
# если какого-то нет, такое событие трекать нельзя
unless ( $self->_check_params($required) ) {
$self->status(FAILURE);
return;
}
# Эти параметры енумы, поэтому они могут иметь лишь определённые значения
# Если значение хоть одного параметра нам не известно,
# Запишем параметры события в другую таблицу, чтобы обработать потом, когда научимся
unless ( $self->_check_enum_params($required) ) {
$self->status(UNKNOWN);
return;
}
my $params = $self->_parse_params([@$required, @$optional]);
$event_param = {
%$params,
%$event_param
};
}
{
my $required = ['time', 'deviceID', 'clientVersion'];
my $optional = ['userToken', 'count', 'description'];
# contentID опционален, если contentType eq 'none'
push @{
$event_param->{'contentType'} eq CONTENT_TYPE_NONE ? $optional : $required
}, 'contentID';
# проверим что есть нужные параметры
# если какого-то нет, такое событие трекать нельзя
unless ( $self->_check_params($required) ) {
$self->status(FAILURE);
return;
}
my $params = $self->_parse_params([@$required, @$optional]);
$event_param = {
# если опциональных параметров нет, всё равно нужно их добавить
(map { $_ => undef } @$optional),
%$params,
%$event_param,
};
}
$event_param->{'time'} = Birdy::TimeUtils::unix2date(
$event_param->{'time'}
);
$self->status(SUCCESS);
$self->params($event_param);
return;
}
# вернёт hashref с запрошенными параметрами
sub _parse_params {
my ($self, $params) = @_;
$params = [] if ref $params ne 'ARRAY';
my $result = {};
foreach my $key (@$params) {
my $value = $self->params->{$key};
next unless $value;
$result->{$key} = $value;
}
return $result;
}
В процессе внедрения мы обнаружили некоторые странности. Например, часть событий приходило в далеком будующем, часть в прошлом. Несложно догадаться что все эти пользователи были счастливые обладатели смартфонов на андроид. Но в целом — всё удалось. Система исправно собирает статистику и мы еле успеваем ее осознать.
В следующих статьях мы планируем подробнее остановиться на методике анализа усвоения контента, как построить DWH/OLAP систему из говна и палок, а так же подробнее расскажем о прощальных письмах и к каким смешным результатам это приводит.
Автор: recompileme