История нашего open source: как мы сделали сервис аналитики на Go и выложили его в открытый доступ

В настоящее время практически каждая компания в мире собирает статистику о действиях пользователя на web ресурсе. Мотивация понятна — компании хотят знать как используется их продукт/веб сайт и лучше понимать своих пользователей. Конечно на рынке существует большое количество инструментов для решения данной проблемы — от систем аналитики, которые предоставляют данные в виде дашбордов и графиков (например Google Analytics) до Customer Data Platform, которые позволяют собирать и агрегировать данные из разных источников в любом хранилище (например Segment).

Но мы нашли проблему, которая еще не была решена. Так родился EventNative — open-source сервис аналитики. O том, почему мы пошли на разработку собственного сервиса, что нам это дало и что в итоге получилось (с кусками кода), читайте под катом

xglaja0hw_ck3kzimbzeqjut99w.jpeg


Зачем нам разрабатывать собственный сервис?


Это были девяностые, мы выживали как могли. 2019 год, мы разрабатывали API First Customer Data Platform kSense, которая позволяла агрегировать данные из разных источников (Facebook ads, Stripe, Salesforce, Google play, Google Analytics и др) для более удобного анализа данных, выявления зависимостей и т.д. Мы заметили, что многие пользователи используют нашу платформу для анализа данных именно Google Analytics (далее GA). С некоторыми пользователями мы поговорили и выяснили, что им нужны данные аналитики их продукта, которые они получают с помощью GA, но Google сэмплирует данные и для многих GA User interface не является эталоном удобства. Мы провели достаточное количество бесед с нашими пользователями и поняли, что многие также использовали платформу Segment (которая, кстати, буквально на днях была продана за 3.2 млрд$). Они устанавливали Segment javascript пиксель на свой web ресурс и данные о поведении их пользователей загружались в указанную базу данных (например Postgres). Но и у Segment есть свой минус — цена. К примеру, если у веб ресурса 90,000 MTU (monthly tracked users) то необходимо оплатить в кассу ~1,000 $ в месяц. Также была и третья проблема — некоторые расширения для браузера (такие как AdBlock) блокировали сбор аналитики т.к. http запросы из браузера отправлялись на домены GA и Segment. Исходя из желания наших клиентов, мы сделали сервис аналитики, который собирает полный набор данных (без сэмплинга), бесплатный и может работать на собственной инфраструктуре.

Как устроен сервис


Сервис состоит из трех частей: javascript пиксель (который мы впоследствии переписали на typescript), серверная часть реализована на языке GO и в качестве in-house базы данных планировалось использовать Redshift и BigQuery (позже добавили поддержку Postgres, ClickHouse и Snowflake).

Структуру событий GA и Segment решили оставить без изменения. Все, что было нужно, это дублировать все события с web ресурса, где установлен пиксель, в наш бекенд. Как оказалось, это сделать несложно. Javascript пиксель переопределял оригинальный метод библиотеки GA на новый, который дублировал событие в нашу систему.

//'ga' - стандартное название переменной Google Analytics
if (window.ga) {
    ga(tracker => {
        var originalSendHitTask = tracker.get('sendHitTask');
        tracker.set('sendHitTask', (model) => {
            var payLoad = model.get('hitPayload');
            //отправка оригинального события в GA
            originalSendHitTask(model);
            let jsonPayload = this.parseQuery(payLoad);
            //отправка события в наш сервис
            this.send3p('ga', jsonPayload);
        });
    });
}


С пикселем Segment все проще, он имеет middleware методы, одним из них мы и воспользовались.


//'analytics' - стандартное название переменной Segment
if (window.analytics) {
    if (window.analytics.addSourceMiddleware) {
        window.analytics.addSourceMiddleware(chain => {
            try {
		//дублирование события в наш сервис
                this.send3p('ajs', chain.payload);
            } catch (e) {
                LOG.warn('Failed to send an event', e)
            }
	    //отправка оригинального события в Segment
            chain.next(chain.payload);
        });
    } else {
        LOG.warn("Invalid interceptor state. Analytics js initialized, but not completely");
    }
} else {
    LOG.warn('Analytics.js listener is not set.');
}


Помимо копирования событий мы добавили возможность отправлять произвольный json:


//Отправка событий с произвольным json объектом
eventN.track('product_page_view', {
    product_id: '1e48fb70-ef12-4ea9-ab10-fd0b910c49ce',
    product_price: 399.99,
    price_currency: 'USD'
    product_release_start: '2020-09-25T12:38:27.763000Z'
});


Далее поговорим про серверную часть. Backend должен принимать http запросы, наполнять их дополнительной информацией, к примеру, гео данными (спасибо maxmind за это) и записывать в базу данных. Мы хотели сделать сервис максимально удобным, чтобы его можно было использовать с минимальной конфигурацией. Мы реализовали функционал определения схемы данных на основе структуры входящего json события. Типы данных определяются по значениям. Вложенные объекты раскладываются и приводятся к плоской структуре:

//входящий json
{
  "field_1":  {
    "sub_field_1": "text1",
    "sub_field_2": 100
  },
  "field_2": "text2",
  "field_3": {
    "sub_field_1": {
      "sub_sub_field_1": "2020-09-25T12:38:27.763000Z"
    }
  }
}

//результат
{
  "field_1_sub_field_1":  "text1",
  "field_1_sub_field_2":  100,
  "field_2": "text2",
  "field_3_sub_field_1_sub_sub_field_1": "2020-09-25T12:38:27.763000Z"
}


Однако массивы на данный момент просто конвертируются в строку т.к. не все реляционные базы данных поддерживают повторяющиеся поля (repeated fields). Также есть возможность изменять названия полей или удалять их с помощью опциональных правил маппинга. Они позволяют менять схему данных, если это потребуется или приводить один тип данных к другому. К примеру, если в json поле находится строка с timestamp (field_3_sub_field_1_sub_sub_field_1 из примера выше) то для того чтобы создать поле в базе данных с типом timestamp, необходимо написать правило маппинга в конфигурации. Другими словами, тип данных поля определяется сначала по json значению, а затем применяется правило приведения типов (если оно сконфигурировано). Мы выделили 4 основных типа данных: STRING, FLOAT64, INT64 и TIMESTAMP. Правила маппинга и приведения типов выглядят следующим образом:

rules:
  - "/field_1/subfield_1 -> " #правило удаления поля
  - "/field_2/subfield_1 -> /field_10/subfield_1" #правило переноса поля
  - "/field_3/subfield_1/subsubfield_1 -> (timestamp) /field_20" #правило переноса поля и приведения типа


Алгоритм определения типа данных:

  • преобразование json структуры в плоскую структуру
  • определение типа данных полей по значениям
  • применение правил маппинга и приведения типов


Тогда из входящей json структуры:

{
    "product_id":  "1e48fb70-ef12-4ea9-ab10-fd0b910c49ce",
    "product_price": 399.99,
    "price_currency": "USD",
    "product_type": "supplies",
    "product_release_start": "2020-09-25T12:38:27.763000Z",
    "images": {
      "main": "picture1",
      "sub":  "picture2"
    }
}


будет получена схема данных:

"product_id" character varying,
"product_price" numeric (38,18),
"price_currency" character varying,
"product_type" character varying,
"product_release_start" timestamp,
"images_main" character varying,
"images_sub" character varying


Также мы подумали, что пользователь должен иметь возможность настроить партиционирование или разделять данные в БД по другим критериям и реализовали возможность задавать имя таблицы константой или выражением в конфигурации. В примере ниже событие будет сохранено в таблицу с именем, вычисленным на основе значений полей product_type и _timestamp (например supplies_2020_10):

tableName: '{{.product_type}}_{{._timestamp.Format "2006_01"}}'


Однако структура входящих событий может изменяться в runtime. Мы реализовали алгоритм проверки разницы между структурой существующей таблицы и структурой входящего события. Если разница найдена — таблица будет обновлена новыми полями. Для этого используется patch SQL запрос:

#Пример для Postgres
ALTER TABLE "schema"."table" ADD COLUMN new_column character varying


Архитектура


6vkyuvnfyqmpnqwcuuok_ft-beg.png

Зачем нужно записывать события на файловую систему, а не просто писать их сразу в БД? Базы данных не всегда демонстрируют высокую производительность при большом количестве вставок (рекомендации Postgres). Для этого Logger записывает входящие события в файл и уже в отдельной горутине (потоке) File reader читает файл, далее происходит преобразование и определение схемы данных. После того как Table manager убедится, что схема таблицы актуальна — данные будут записаны в БД одним батчем. Впоследствии мы добавили возможность записывать данные напрямую в БД, но применяем такой режим для событий, которых не много — например конверсии.

Open Source и планы на будущее


В какой-то момент сервис стал похож на полноценный продукт и мы решили выложить его в Open Source. На текущий момент реализованы интеграции с Postgres, ClickHouse, BigQuery, Redshift, S3, Snowflake. Все интеграции поддерживают как batch, так и streaming режимы загрузки данных. Добавлена поддержка запросов через API.
Текущая интеграционная схема выглядит следующим образом:

tcy3njz34fveilfm-dgt-fugnfi.png

Несмотря на то что сервис можно использовать самостоятельно (например с помощью Docker), у нас также есть hosted версия, в которой можно настроить интеграцию с хранилищем данных, добавить CNAME на свой домен и посмотреть статистику по количеству событий. Наши ближайшие планы — добавление возможности агрегировать не только статистику с веб ресурса, но и данные из внешних источников данных и сохранять их в любое хранилище на выбор!

GitHub
Документация
Slack

Будем рады если EventNative поможет решить ваши задачи!

© Habrahabr.ru