Упаковываем UserTiming. Часть 1

UserTiming — это современный API, который позволяет разработчикам отмечать наступление важных событий (timestamps — моменты времени) и засекать их длительность (как разность указанных моментов времени). Для получения представления о работе API рекомендуется прочитать статью или заметку с несколькими примерами использования.

UserTiming прост в использовании. Если вы хотите пометить событие, просто вызовите window.performance.mark(markName):

// логгируем начало события
performance.mark("start");

Вы можете вызвать .mark() столько раз, сколько потребуется, и использовать в качестве markName любую строку.
Данные хранятся в PerformanceTimeline. Запросить информацию можно вызовами вида: performance.getEntriesByName(markName):

// get the data back
var entry = performance.getEntriesByName("start");
// -> {"name": "start", "entryType": "mark", "startTime": 1, "duration": 0}

Не правда ли замечательно? И снова порекомендую вам изучить статью для ознакомления с примерами использования.

Представим, что вы убедились в пользе UserTiming и поместили вызовы для отсечки времени выполнения в разные места своего веб приложение. Что дальше?

Данные бесполезны, пока вы не начнете с ними работать. На своем собственном компьютере вы можете запросить PerformanceTimeline и исследовать измерения в browser developer tools. Еще можно использовать сторонние сервисы third party services

Что делать, если вы решили собрать данные? Если вы хотите проследить тенденции и закономерности в собственных программах для анализа?

В качестве решения «в лоб» можно указать сбор всех отсечек с помощью performance.getEntriesByType(), упаковку их в JSON, и отправку на машину для сбора статистики.

Но сколько это будет данных? Сколько вешать в байтах? 🙂

Рассмотрим пример из реальной жизни — тайминги при посещении сайта:

{"duration":0,"entryType":"mark","name": "mark_perceived_load","startTime":1675.636999996641},
{"duration":0,"entryType":"mark","name": "mark_before_flex_bottom","startTime":1772.8529999985767},
{"duration":0,"entryType":"mark","name": "mark_after_flex_bottom","startTime":1986.944999996922},
{"duration":0,"entryType":"mark","name": "mark_js_load","startTime":2079.4459999997343},
{"duration":0,"entryType":"mark","name": "mark_before_deferred_js","startTime":2152.8769999968063},
{"duration":0,"entryType":"mark","name": "mark_after_deferred_js","startTime":2181.611999996676},
{"duration":0,"entryType":"mark","name": "mark_site_init","startTime":2289.4089999972493}]

Получилось 657 байт для всего семи отметок. А если вам надо логгировать сотни или тысячи важных эвентов с ваших страниц? А что если ваше приложение состоит из одной страницы, на которой в рамках одной пользовательской сессии может быть сгенерировано множество событий?

Разумеется, мы можем сделать всё красивее. Намекну: отправка JSON – это не хорошо. Как разработчики, нацеленный на производительность, мы должны стремиться минимизировать количество данных, которые наше приложение пошлет с пользовательского компьютера.

Вот что мы можем сделать.

Цель

Наша цель заключается в уменьшении размера массива меток и отсечек времени. Должна получиться структура данных с минимальным размером, которая соответственно минимизирует отправляемые данные.

Для ознакомления со схожей методикой сжатия для ResourceTiming , прочитайте статью Compressing ResourceTiming. Для сжатия таймингов мы будем использовать похожие принципы.

Дополнительной целью будет использование техники, при которой сжатые данные не меняют своего вида при использовании алгоритма URL encoding (к примеру, запятая становится %2C). В этом случае можно будет использовать существующие средства анализа.

Подход

Существуют две главные области нашей структуры данных, которые могут быть подвергнуты сжатию. Разберем такой пример:

{  
    "name":      "measureName",
    "entryType": "measure",
    "startTime": 2289.4089999972493,
    "duration":  100.12314141 
}

Какие данные важны тут? Каждая метка и измерение имеют 4 атрибута:

1. Имя
2. Тип — метка или измерение
3. Стартовый момент врмени
4. Длительность (для меток равна 0)
Я предлагаю разбить эти атрибуты на две области: объект и полезная нагрузка. Объект — это просто имя. Полезная нагрузка — это стартовый момент времени, и в случае если тип это измерение, еще и длительность. Длительность подразумевает, что тип объекта — это измерение, поэтому этот атрибут не нужно отслеживать независимо.

По большому счету, мы можем разбить наши тайминги на пары ключ-значение. Если мы сгруппируем данные по имени, то сможем делать интересные вещи. Значение (полезная нагрузка) будет списком стартовых моментов времени и длительностей.

Во-первых, мы пережмем полезную нагрузку (все timestamp и длительности). Ну а потом, мы можем сжать список объектов.

Сжатие timestamp-ов

Первым делом мы хотим сжать для каждого имени метки/измерения их timestamp.

Стартовый момент времени и длительность предоставляются в виде числа миллисекунд с микросекундной точностью в дробной части. Большинству разработчиков не нужна часть с микросекундами, а ведь именно она дает много знаков в полезной нагрузке. Стартовое время 2289.4089999972493 можно переформатировать в 2289 миллисекунды не теряя точности.

Пусть у нас есть 3 метки:

{"duration":0,"entryType":"mark","name":"mark1","startTime":100},
{"duration":0,"entryType":"mark","name":"mark1","startTime":150},
{"duration":0,"entryType":"mark","name":"mark1","startTime":500}

Группируем их по имени:

{ "mark1": [100, 150, 500] }

При использовании вызова performance.getEntries() мы получаем данные в отсортированном порядке. Почему бы не использовать это? Давайте будем брать первое (наименьшее) значение (100) и считать смещения от него:

{ "mark1": [100, 50, 350] }

Как можно дополнительно сжать числа? Вспомним о том, что целью является передача данных в URL (query string), т.е. мы хотим использовать набор символов ASCII.

Простым методом сжатия в javascript является использование Base-36. Поясню: 0=0, 10=a, 35=z . В JavaScript есть встроенный вызов Integer.toString(36) :

(35).toString(36)          == "z" (экономим 1 символ)
(99999999999).toString(36) == "19xtf1tr" (экономим 3 символа)

При использовании этого алгоритма массив превращается в:

{ "mark1": ["2s", "1e", "9q"] }

Теперь мы можем объединить элементы массива в одну строку для упрощения пересылки. Запятую в качестве разделителя не стоит — она зарезервирована в документации. Запятая будет перекодирована в %2C.

Вообще, список кандидатов в разделители невелик:

[0-9a-zA-Z] $ - _ . + ! * ' ( )

Точка . очень похожа на запятую, берем ее. Вызываем Array.join(«.») и получаем:

{ "mark1": "2s.1e.9q" }

Похоже, мы действительно достигли успехов в сжатии. Но это еще не предел!

Допустим, у нас есть еще данные:

{"duration":0,"entryType":"mark","name":"mark1","startTime":100},
{"duration":0,"entryType":"mark","name":"mark1","startTime":200},
{"duration":0,"entryType":"mark","name":"mark1","startTime":300}

При сжатии получаем:

{ "mark1": "2s.2s.2s" }

И тут нам пригодится символ звездочки *, ее будем использовать в случае, когда разность/смещение момента времени повторяется:

* означает повтор дважды
*[n] означает повтор n раз

Тогда получим:

{ "mark1": "2s*3" }

Очевидно, результаты этого шага компрессии зависят от характеристик приложения, но на практике можно увидеть повторы в данных, а следовательно возможность сокращения.

Длительности

А что с измерениями? У них есть дополнительный атрибут duration. Для меток он всегда равен 0, а вот для измерений длительности это число миллисекунд.

Мы можем адаптировать строку и включить длительности, если они ненулевые. Мы даже можем мешать метки и длительности с одним и тем же именем и будем отличать их.

Разберем пример — одна метка и два измерения длительности с одним именем:

{"duration":0,"entryType":"mark","name":"foo","startTime":100},
{"duration":100,"entryType":"measure","name":"foo","startTime":150},
{"duration":200,"entryType":"measure","name":"foo","startTime":500}

Вместо массива закодированных в Base36 смещений нам надо включить длительность, если она есть. Для этого выберем еще один символ, который не искажается при URI кодировании — подчеркивание _.
Для стартового времени 150 (1e в Base-36 ) и длительности 100 (2s в Base-36) получаем строку 1e_2s.

Комбинируя эти шаги получаем:

{ "foo": "2s.1e_2s.9q_5k" }

При декодировании метки от измерений длительности можно отличать по признаку: только у измерений есть длительность.

Возвращаясь к старому примеру данных:

[{"duration":0,"entryType":"mark","name":"mark1","startTime":100},
{"duration":0,"entryType":"mark","name":"mark1","startTime":150},
{"duration":0,"entryType":"mark","name":"mark1","startTime":500}]

Этот JSON мы перекодировали в другой (т.е. результат пока нельзя назвать дружелюбным или совместимым по отношению к передаче в URI):

{"mark1":"2s.1e.9q"}

Было 198 байт, стало 21 байт, т.е. примерно 10% от первоначального размера, что не так уж плохо.

Сжимаем массив

Большинство сайтов использует не одну метку и измерение длительности, поэтому при передаче данных придется иметь дело с массивом.

Мы сжали моменты времени в дружественную по отношению к URI передаче строку, но как поступить в случае передачи массива меток/измерений и их моментов времени (timestamp)?

Допустим, у нас есть три метки и 3 измерения времени на странице, у каждого свой timestamp. После применения сжатия получим:

 {
    "mark1": "2s",
    "mark2": "5k",
    "mark3": "8c",
    "measure1": "2s_2s",
    "measure2": "5k_5k",
    "measure3": "8c_8c"
}

И тут есть несколько способов упаковки в строку для передаче в качестве части URL.

Используем массив

Вспомним, что JSON неудобен для URI передачи, т.к. Символы {} “ : будут экранированы.

Даже после упаковки JSON:

{"mark1":"2s","mark2":"5k","mark3":"8c","measure1
":"2s_2s","measure2":"5k_5k","measure3":"8c_8c"}
(98 байт)

даст при URI кодировании такую строку:

%7B%22mark1%22%3A%222s%22%2C%22mark2%22%3A%225k%22
%2C%22mark3%22%3A%228c%22%2C%22measure1%22%3A%22
2s_2s%22%2C%22measure2%22%3A%225k_5k%22%2C%22meas
ure3%22%3A%228c_8c%22%7D
(174 байт)

Это же 77%-ный оверхед!

Т.к. у нас есть список ключей (имен) и значений, то мы можем преобразовать этот объект в «массив», в котором мы не используем в качестве разделителей символы {} “ :

Давайте рассмотрим еще один неэкранируемый символ в качестве разделителя — тильду ~. Предлагаемый формат:

[name1]~[timestamp1]~[name2]~[timestamp2]~[...]

Для наших данных:

mark1~2s~mark2~5k~mark3~8c~measure1~2s_2s~measure2~5k_5k~measure3~8c_8c~
(73 байт)

Тут предполагается, что в именах нет тильд. Если они есть, то до применения упаковки их нужно экранировать в %7E.

Используем префиксное дерево (Trie)

Один метод мы уже рассмотрели. В некоторых случаях можно упаковать более эффективно, особенно если имена похожи.

Одна из использованных техник сжатия ResourceTiming — это оптимизированный Trie.

В приведенном выше примере mark1, mark2 and mark3 есть простор для применения такой техники, ведь у имен есть общий «ствол» (префикс) mark. В оптимизированном Trie вид будет такой:

{
    "mark": {
        "1": "2s",
        "2": "5k",
        "3": "8c"
    },
    "measure": {
        "1": "2s_2s",
        "2": "5k_5k",
        "3": "8c_8c"
    }
}

При минимизации получим строку на 13% короче:

{"mark":{"1":"2s","2":"5k","3":"8c"},"measure":{"
1":"2s_2s","2":"5k_5k","3":"8c_8c"}}
(86 байт)

Но теперь метод сжатия в массив с тильдой не так просто применить, ведь мы теперь имеем дело с неплоской структурой данных, а древовидной.

Вообще, есть способ подготовить этот JSON для передачи в качестве части URL, называется JSURL. JSURL заменяет недружелюбные к URI символы более дружелюбным представлением. Для нашего JSON получим такую URI версию:

%7B%22mark%22%3A%7B%221%22%3A%222s%22%2C%222%22%3
A%225k%22%2C%223%22%3A%228c%22%7D%2C%22measure%22
%3A%7B%22%0A1%22%3A%222s_2s%22%2C%222%22%3A%225k_
5k%22%2C%223%22%3A%228c_8c%22%7D%7D
(185 байт)

A вот что даст JSURL:

~(m~(ark~(1~'2s~2~'5k~3~'8c)~easure~(1~'2s_2s~2~'5k_5k~3~'8c_8c)))
(67 байт)

Как можно заметить, оптимизированный Trie дал 10% выигрыш в сжатии.

Используем Map

Наконец, если вы знаете, что ваши метки/измерения будут переданы заранее, то передавать фактические имена необязательно. Если набор имен конечен, то можно хранить ассоциативный массив (map) c парами имя:индекс и передавать значение индекса вместо имени.
Для уже разобранного примера:

{
    "mark1": "2s",
    "mark2": "5k",
    "mark3": "8c",
    "measure1": "2s_2s",
    "measure2": "5k_5k",
    "measure3": "8c_8c"
}

Отобразим имена на значения индекса в диапазоне 0-5:

{
    "mark1": 0,
    "mark2": 1,
    "mark3": 2,
    "measure1": 3,
    "measure2": 4,
    "measure3": 5
}

Мы больше не используем Trie, значит можем вернуться к оптимизированному массиву. Размер диапазона индекса относительно небольшой (значения 0-35 можно упаковать в 1 символ), поэтому мы можем получить выигрыш при сжатии благодаря отказу от тильды.
Формат становится таким:

[index1][timestamp1]~[index2][timestamp2]~[...]

Для наших данных:

02s~15k~28c~32s_2s~45k_5k~58c_8c
(32 байта)

Размер меньше половины строки, получаемой от оптимизированного Trie.

Если имен будет больше 36, то мы все равно можем разместить их в этой структуре.
Помните, что значение 36 (тридцать седьмое по порядку, у нас нумерация с нуля) даст 10 (36).toString(36) == 10, а это уже два символа. Мы не можем использовать индекс из 2 символов, т.к. ранее предполагалось, что значение индекса — это один символ.
Один из путей борьбы с этим затруднением — добавление специальной кодировки, если значение индекса больше порогового значения. Будем использовать еще один неэкранируемый символ — :
0-z (значения индекса 0 – 35), это и есть значение индекса
-, следующие два символа будут значением индекса (плюс 36)
Таким образом, 0 будет закодирован в 0, 35 в z, 36 в -00, 1331 в -zz. Мы сможем закодировать 1331 значение максимум.

Для нашего примера:

{
    "mark1": "2s",
    "mark2": "5k",
    "mark3": "8c"
}

Отображение пусть будет таким:

{
    "mark1": 36,
    "mark2": 37,
    "mark3": 1331
}

Пережмется в:

-002s~-015k~-zz8c

Теперь у нас есть 3 метода сжатия результатов работы. Между ними даже можно переключаться в зависимости от данных, которые получены от UserTiming.

Продолжение следует.
Английский оригинал.

You can leave a response, or trackback from your own site.

Leave a Reply