Как R облегчает мою жизнь разработчика

—

от автора

Как разработчик, я ежедневно сталкиваюсь с большим количеством данных, которые нужны для принятия каких‑либо решений. Логи, конфиги, данные профилирования, аналитические выгрузки из БД и даже сведения о том, когда был написан данный код — это всё данные. Иногда бывает достаточно посмотреть глазами, и картина станет ясной. Но чем больше данных, тем меньше помогает «метод пристального взгляда» и тем нужнее какие‑то инструменты анализа — а у нас в Яндекс Еде данных бывает очень много.

Иногда можно собрать нужную информацию, просто скомбинировав несколько линуксовых команд пайпом (cat data.log | grep … | awk .. | sort | uniq -c | sort -r | head), иногда пригодятся электронные таблицы, иногда проще написать небольшую программку для анализа данных. Но когда я освоился с языком R и его экосистемой, то всё это стало ненужным.

Представьте, что у вас есть небольшая аналитическая in‑memory база данных с полностью динамической структурой, поддержкой любых типов в полях, в том числе и объектов со структурой любой сложности. А ещё удобный язык запросов к ней, импорт и экспорт популярных форматов данных из любых источников, хоть из буфера обмена. Всё это бесплатно, с удобным GUI и мощным движком визуализации данных.

Узнал я про R практически случайно: на моей первой работе нужно было сделать что‑то, чтобы заменить огромный лист с формулами в Excel (сотня столбцов и десятки тысяч строк), который тормозил всё больше и больше. С тех пор я использовал R как более удобную и мощную альтернативу электронным таблицам, когда нужно посчитать какую‑нибудь статистику на датасетах размером в миллионы строк и построить красивые графики, которые помогают представить какие‑то выводы руководству в простом и наглядном виде.

В какой‑то момент я заинтересовался языком и его библиотеками подробнее, начал основательно читать документацию (а не просто применять знакомые рецепты), и вдруг меня осенило: значительная часть моей работы (исключая написание кода, разумеется) — это работа с данными. А это значит, к ним можно применить специально для этого сделанный инструмент…

Сейчас это новообретённое осознание упрощает мою работу. Работа с данными с помощью R помогает мне, во‑первых, быть уверенным в результатах анализа, а во‑вторых, делает его в большинстве случаев лёгким, быстрым и приятным. В статье я покажу несколько примеров, где я «увидел данные» и сделал свою работу более эффективной.

Tidyverse — библиотеки, которые упрощают жизнь

Эффективная работа с данными в R во многом возможна благодаря библиотекам tidyverse. Они спроектированы так, чтобы им было удобно работать друг с другом: например, у них единообразное наименование функций, а также одинаковый подход к аргументам и принципам работы.

Один из ключевых компонентов этой экосистемы — пакет magrittr. Он вводит pipe‑оператор, благодаря которому конструкции вида f(g(h(x, 1), 'col'), 'mean') превращаются в изящное x %>% h(1) %>% g('col') %>% f('mean'), где аргументы функций не отрываются от их имён.

Скажу больше: эта штука оказалась настолько удобной, что в сам язык R версии 4.1.0 был введён встроенный оператор |> с почти той же функциональностью. Благодаря ему обработка данных становится очень похожа на наращивание команд через пайп в шелле (вида grep … | cut … | awk …). Вот только вместо просто текстовых потоков между функциями обычно передаются таблички с данными.

Tidy Data

Tidy Data — это принцип организации табличных данных. Но на самом деле он организует не столько данные, сколько мышление. Он до банальности прост:

  1. Одна строка — одно наблюдение.

  2. Один столбец — одна переменная.

  3. Одна ячейка — одно значение.

Польза его не столько в том, что он декларирует, по сути, очевидные вещи (концепция очень близка к нормальным формам БД), а скорее в том, что он заставляет задуматься: «Что нужно рассматривать как одно наблюдение? А что есть одно значение?»

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

Как это выглядит на практике

Первый пример: сравнение feature-флагов

Начнём с простого. Как и в любом большом проекте, у нас в Яндекс Еде есть множество фича-флагов. Задача — понять, значения каких флагов совпадают в проде и на тестинге, а какие различаются. Хранятся они все в виде большого JSON-файла. Причём по историческим причинам формат этого файла таков, что описание флага вписывается не в схеме, а в самом конфиге.

{   "feature_flag_1": {      "description": "Здесь какое-то описание",      "enabled": true   },   "feature_flag_2": {      "description": "Здесь другое описание",      "enabled": false   } }

Поэтому просто взять два JSON‑файла и сделать diff не получается: вылезает множество различий в описаниях (которые не важны), или где‑то diff вместо различия в значениях находит различия в названиях. Это нам не подходит.

Предлагаю сделать небольшую паузу и подумать, как бы вы решили эту задачу, совершая минимум действий с клавиатуры. Среди идей от коллег, например, вставить JSON в IPython‑ноутбук в виде строкового значения, разобрать и сравнить, преобразовав данные в set.

Мой вариант с R укладывается в три строки, одну из которых даже не нужно полностью набирать.

# (копируем в буфер конфигурацию теста) clipboard() %>% parse_json %>% enframe %>% unnest_wider(value) -> test # (копируем в буфер конфигурацию прода) clipboard() %>% parse_json %>% enframe %>% unnest_wider(value) -> prod # нажал вверх и исправил четыре символа  # смотрим разницу, приводя отсутствующие значения к False prod %>% full_join(test, by='name') %>% filter(coalesce(enabled.x, F) != coalesce(enabled.y, F)) %>% View

Разумеется, предварительно у меня уже есть открытая RStudio, в которой подключены нужные библиотеки: library(tidyverse) и library(jsonlite).

Как это работает в R

А теперь давайте разбираться, что же здесь произошло.

Как мы помним, цепочки вида a %>% b %>% c %>% d — это последовательный вызов функций. clipboard(), как несложно понять, просто возвращает содержимое буфера обмена. Далее начинает работать функция parse_json из пакета jsonlite, которая превращает json‑текст в list. А list в точности отражает иерархическую структуру JSON.

Так, из примера в JSON получится следующий список:

list(feature_flag_1 = list(description = "Здесь какое-то описание",      enabled = TRUE), feature_flag_2 = list(description = "Здесь другое описание",      enabled = FALSE))
Можно его вывести красивее…

…с помощью функции tree из пакета lobstr.

lobstr::tree(l) #> <list> #> ├─feature_flag_1: <list> #> │ ├─description: "Здесь какое-то описание" #> │ └─enabled: TRUE #> └─feature_flag_2: <list> #>   ├─description: "Здесь другое описание" #>   └─enabled: FALSE

Далее мы начинаем приводить эти данные к tidy‑виду: одна строка — одно наблюдение, один столбец — одна переменная. «Наблюдение» здесь — один фича‑флаг, а «переменные» — описание и признак включённости.

Сначала мы превращаем список в таблицу с помощью функции enframe. На выходе получаем такую табличку:

> l %>% enframe # A tibble: 2 × 2   name           value              <chr>          <list>           1 feature_flag_1 <named list [2]> 2 feature_flag_2 <named list [2]>

В первом столбце таблицы — ключи объекта, во втором — значения. Но так как значения не простые, а составные, то они представлены в виде списка.

list(description = "Здесь какое-то описание", enabled = TRUE)

Чтобы «развернуть» такой список в столбцы, есть функция unnest_wider. Она собирает все возможные имена, вложенные в этот список, и распределяет их по столбцам. Там, где данные отсутствуют, появится значение NA. Таким образом, у нас получается датафрейм следующего вида:

> l %>% enframe %>% unnest_wider(value) # A tibble: 2 × 3   name           description             enabled   <chr>          <chr>                   <lgl>   1 feature_flag_1 Здесь какое-то описание TRUE    2 feature_flag_2 Здесь другое описание   FALSE 

Поначалу это заклинание может выглядеть довольно непростым, однако к нему очень быстро привыкаешь. Что‑то типа ... %>% parse_json %>% enframe %>% unnest_wider(value) я пишу постоянно.

Соответственно, после выполнения первых двух команд у меня есть два датафрейма — для боевого и тестового окружения. Теперь осталось их соединить и сравнить.

prod %>%    full_join(test, by='name') %>%    filter(coalesce(enabled.x, F) != coalesce(enabled.y, F)) %>%    View
Как работает full_join и filter

Функция full_join полностью отвечает своему наименованию. Поскольку соединение происходит по столбцу name, столбцы description и enabled раздваиваются: значения из первого датафрейма становятся description.x и enabled.x, а из второго, соответственно, description.y и enabled.y. Соединение здесь используется типа FULL, а не INNER — оно и порождает отсутствующие значения, которые в R обозначаются как NA.

# A tibble: 3 × 5   name           description.x           enabled.x description.y              enabled.y   <chr>          <chr>                   <lgl>     <chr>                       <lgl>     1 feature_flag_1 Здесь какое-то описание    TRUE      Здесь какое-то описание    TRUE      2 feature_flag_2 Здесь другое описание      FALSE     NA                         NA        3 feature_flag_3 NA                         NA        Здесь другое описание      TRUE

Далее нам нужно найти те флаги, у которых статус на проде и на тесте отличается. Можно было бы написать %>% filter(enabled.x != enabled.y), но, как и в SQL, сравнение с NA приводит к NA, которое в булевом контексте приводится к FALSE. Например, если на проде флаг включён, а на тесте отсутствует (что с точки зрения кода означает, что флаг выключен), то сравнение TRUE != NA будет иметь значение NA. Такое различие с помощью подобного сравнения мы не найдём.

На SQL для решения этой задачи мы бы написали COALESCE(enabled, FALSE). На R мы пишем: coalesce(enabled.x, F), приводя отсутствующие значения к FALSE. Теперь сравнение работает корректно.

Ну и наконец, функция View выводит пример в специальном окне RStudio.

Когда все нужные функции tidyverse вспоминаются примерно с той же скоростью, что и операторы SQL, то подобное сравнение (включая копирование конфигов, переключение между окошками, просмотр промежуточных результатов) занимает меньше минуты.

Второй пример: более сложный, со встроенным разбором

Давайте усложним задачу — нужно выяснить, кто, когда и какие значения менял. В силу особенностей хранения, опять‑таки, конфиг перезаписывается целиком с указанием времени и автора изменений. Для простоты предположим, что история у нас есть в следующем виде:

[{"author":"pupkin","datetime":"2024-01-17 16:54:23","value":{"feature_flag_1":{"description":"Enable dark mode","enabled":true},"feature_flag_2":{"description":"Show notifications","enabled":false}}},{"author":"ivanov","datetime":"2024-01-18 09:12:45","value":{"feature_flag_1":{"description":"Enable dark mode","enabled":false},"feature_flag_2":{"description":"Show notifications","enabled":true},"feature_flag_3":{"description":"Enable beta features","enabled":true}}},{"author":"pupkin","datetime":"2024-01-19 11:30:10","value":{"feature_flag_1":{"description":"Enable dark mode","enabled":true},"feature_flag_2":{"description":"Show notifications","enabled":true},"feature_flag_3":{"description":"Enable beta features","enabled":false},"feature_flag_4":{"description":"Allow location access","enabled":true},"feature_flag_5":{"description":"Enable offline mode","enabled":true}}},{"author":"ivanov","datetime":"2024-01-20 14:45:33","value":{"feature_flag_1":{"description":"Enable dark mode","enabled":false},"feature_flag_2":{"description":"Show notifications","enabled":false},"feature_flag_3":{"description":"Enable beta features","enabled":true},"feature_flag_4":{"description":"Allow location access","enabled":true}}},{"author":"pupkin","datetime":"2024-01-21 08:22:17","value":{"feature_flag_1":{"description":"Enable dark mode","enabled":true},"feature_flag_2":{"description":"Show notifications","enabled":true},"feature_flag_3":{"description":"Enable beta features","enabled":false}}}]

Для начала разберём её в list, а затем превратим в таблицу.

clipboard() %>% parse_json %>% enframe %>% unnest_wider(value)

…и обнаружим, что данные всё ещё далеки от tidy-представления: одна строка — это одна запись в логе, а содержимое спрятано в двухуровневой иерархии в value. Таким образом, в одной строке — сразу множество наблюдений, а в одной ячейке — множество значений.

С такой структурой данных мы можем утомительно делать unnest_wider несколько раз, но лучше воспользоваться удобной фичей пакета jsonlite, который может разворачивать несложные json-файлы в датафреймы за нас:

> clipboard() %>% fromJSON(flatten=T) %>% glimpse Rows: 5 Columns: 12 $ author                           <chr> "pupkin", "ivanov", "pupkin", "ivanov", "pupkin" $ datetime                         <chr> "2024-01-17 16:54:23", "2024-01-18 09:12:45", "2024-01-19 11:30:10", "2024-01-20 14:45:33", "2024-01-2… $ value.feature_flag_1.description <chr> "Enable dark mode", "Enable dark mode", "Enable dark mode", "Enable dark mode", "Enable dark mode" $ value.feature_flag_1.enabled     <lgl> TRUE, FALSE, TRUE, FALSE, TRUE $ value.feature_flag_2.description <chr> "Show notifications", "Show notifications", "Show notifications", "Show notifications", "Show notifica… $ value.feature_flag_2.enabled     <lgl> FALSE, TRUE, TRUE, FALSE, TRUE $ value.feature_flag_3.description <chr> NA, "Enable beta features", "Enable beta features", "Enable beta features", "Enable beta features" $ value.feature_flag_3.enabled     <lgl> NA, TRUE, FALSE, TRUE, FALSE $ value.feature_flag_4.description <chr> NA, NA, "Allow location access", "Allow location access", NA $ value.feature_flag_4.enabled     <lgl> NA, NA, TRUE, TRUE, NA $ value.feature_flag_5.description <chr> NA, NA, "Enable offline mode", NA, NA $ value.feature_flag_5.enabled     <lgl> NA, NA, TRUE, NA, NA 
Зачем нужен glimpse и flatten=T

glimpse показывает список и первые значения каждого столбца, без неё широкая табличка показывается не слишком удобно:

> clipboard() %>% fromJSON(flatten=T)   author            datetime value.feature_flag_1.description value.feature_flag_1.enabled value.feature_flag_2.description value.feature_flag_2.enabled value.feature_flag_3.description 1 pupkin 2024-01-17 16:54:23                 Enable dark mode                         TRUE               Show notifications                        FALSE                             <NA> 2 ivanov 2024-01-18 09:12:45                 Enable dark mode                        FALSE               Show notifications                         TRUE             Enable beta features 3 pupkin 2024-01-19 11:30:10                 Enable dark mode                         TRUE               Show notifications                         TRUE             Enable beta features 4 ivanov 2024-01-20 14:45:33                 Enable dark mode                        FALSE               Show notifications                        FALSE             Enable beta features 5 pupkin 2024-01-21 08:22:17                 Enable dark mode                         TRUE               Show notifications                         TRUE             Enable beta features   value.feature_flag_3.enabled value.feature_flag_4.description value.feature_flag_4.enabled value.feature_flag_5.description value.feature_flag_5.enabled 1                           NA                             <NA>                         NA                             <NA>                           NA 2                         TRUE                             <NA>                         NA                             <NA>                           NA 3                        FALSE            Allow location access                       TRUE              Enable offline mode                         TRUE 4                         TRUE            Allow location access                       TRUE                             <NA>                           NA 5                        FALSE                             <NA>                         NA                             <NA>                           NA

Аргумент flatten=T нужен для того, чтобы функция вернула не вложенные друг в друга датафреймы (иногда это полезно, но не сейчас), а одну табличку

Мы получили не очень удобную табличку с кучей столбцов (в реальности их больше 1200), но это лишь промежуточное представление. В нём есть определённая польза: заметим, что благодаря автоматической конвертации сходных JSON‑структур в таблицы на месте пропущенных значений сами собой появились NA (мы потом превратим их в FALSE).

А в каком виде нам нужны конечные данные? Задача ведь узнать, кто и когда менял значения и какие именно. Значит, одним наблюдением здесь будет «изменение одного значения», и ему должна соответствовать одна строка. Столбцы будут такие: кто, когда, какой флаг и на какое значение.

Первым шагом приведём табличку в более приятный вид: удалим лишние столбцы с описаниями и превратим её из «широкой» в «высокую» — чтобы все названия флагов попали в один столбец.

src %>% fromJSON(flatten=T) %>%   select(-ends_with('.description')) %>%    pivot_longer(ends_with('.enabled'))  # A tibble: 25 × 4    author datetime            name                         value    <chr>  <chr>               <chr>                        <lgl>  1 pupkin 2024-01-17 16:54:23 value.feature_flag_1.enabled TRUE   2 pupkin 2024-01-17 16:54:23 value.feature_flag_2.enabled FALSE  3 pupkin 2024-01-17 16:54:23 value.feature_flag_3.enabled NA     4 pupkin 2024-01-17 16:54:23 value.feature_flag_4.enabled NA     5 pupkin 2024-01-17 16:54:23 value.feature_flag_5.enabled NA     6 ivanov 2024-01-18 09:12:45 value.feature_flag_1.enabled FALSE  7 ivanov 2024-01-18 09:12:45 value.feature_flag_2.enabled TRUE   8 ivanov 2024-01-18 09:12:45 value.feature_flag_3.enabled TRUE   9 ivanov 2024-01-18 09:12:45 value.feature_flag_4.enabled NA    10 ivanov 2024-01-18 09:12:45 value.feature_flag_5.enabled NA    # ℹ 15 more rows # ℹ Use `print(n = ...)` to see more rows
Что за магия тут происходит

Функция pivot_longer делает таблицу «выше и уже», соединяя выбранные столбцы в один и добавляя новый столбец, в котором хранятся имена бывших столбцов.

Как работает функция pivot_longer

Как работает функция pivot_longer

ends_with — особая конструкция, работающая внутри функции select и в аналогичных местах, позволяющая выбрать столбцы по концу имени, а если перед ней поставить минус, то эти столбцы будут, напротив, исключены. Подробнее про селекторы можно прочитать в документации.

Поэтому мы с её помощью сначала исключаем ненужные столбцы, а потом выбираем те, которые нужно «схлопнуть», и получаем результат.

Также нужно заменить отсутствующие значения на FALSE и сделать время временем, а не строкой:

… %>% mutate(value = coalesce(value, F)) %>% type_convert

Функция type_convert пытается угадать тип данных в столбце — здесь это и поможет преобразовать строки в даты.

Теперь осталось сгруппировать их по имени флага и выбрать только те значения, где они не совпадают с предыдущим:

… %>% arrange(datetime) %>% group_by(name) %>% filter(value != lag(value, default=F)) 

Или, если нам не нужно сохранять группировку по имени (чтобы, например, посчитать количество изменений по каждому флагу), можно написать так:

… %>% arrange(datetime) %>% filter(value != lag(value, default=F), .by=name)

Или даже так:

… %>% filter(value != lag(value, default=F, order_by=datetime), .by=name)

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

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

Как ещё можно покрутить эти данные

В этот момент можно сохранить полученный датафрейм в какую‑нибудь переменную и продолжить над ним эксперименты. Например, сгруппировать их по авторам и посчитать, кто больше всего менял фича‑флаги.

%>% group_by(author) %>% tally(sort=T)

Или посчитать количество флагов, которые трогал каждый разработчик.

%>% group_by(author) %>% summarise(flags = n_distinct(name), changes=n())

Можно воспользоваться пакетом ggplot2 (также часть tidyverse) и построить график активности пользователей.

… %>% ggplot(aes(x=datetime, color=as.factor(author)) %>% geom_density()

Или что угодно ещё — возможности совершенно безграничны.

Обзор реальных кейсов

Теперь, когда читатель понял удобство интерактивной работы, я перейду к более сложным и продвинутым примерам. В них раскрывается истинная мощь такого подхода к данным. В целом общая тактика такова:

  1. Загрузить какие‑то данные.

  2. Привести каждый набор к tidy‑виду.

  3. Агрегировать как нужно.

  4. Посмотреть на итоговый результат разными способами.

  5. При необходимости найти больше данных и повторить.

Реальная задача с Feature Flags

Реальная задача с feature flags на самом деле выглядела так:

  1. Получить список флагов из кода при помощи статического анализатора (да, я люблю писать плагины к Psalm). Данные включают в себя наименование флага, а также файл и номер строки, где он объявлен.

  2. Сравнить флаги, которые объявлены в коде и которые сконфигурированы в проде. Тут обнаружилось, что часть флагов из кода в принципе никогда не включались ни на проде, ни на тесте.

  3. Поскольку статический анализатор выдаёт файл и номер строки, с помощью инструмента blame можно узнать, кто его там написал. Здесь можно вытянуть столбец прямо из таблички, превратить его в шелл‑команды, запустить, распарсить вывод — и это всё при желании можно запихнуть в одну длинную команду с множеством %>%. Правда, поскольку blame — операция затратная, результаты я тут же сохранил в отдельную переменную.

  4. Далее на основании этих данных можно строить графики, приходить к авторам и выяснять, по какой причине флаг никогда не включался, и так далее.

Из интересных приёмов, которые здесь пригодились, — создание столбцов со значениями‑списками и последующее склеивание их в команды. Чтобы blame по файлам, где используются фича‑флаги, был быстрее, его стоит запрашивать не по всему файлу, а по отдельным строкам с упоминаниями. В одном файле таковых упоминаний может быть несколько, и в идеале команда должна выглядеть так:

arc blame --json -L 100,100 -L 208,208 -L 514,514 File.php

Arc — это наша система контроля версий. О ней мы писали несколько лет назад.

Выполнять команду с захватом вывода можно с помощью system(command, intern=T), но как её сформировать? Жизнь облегчает пакет glue:

ff_usages %>%    mutate(linepart = glue::glue("-L {line},{line}")) %>%   group_by(path) %>%    summarise(lines=str_flatten(linepart, " ")) %>%    glue::glue_data("arc blame --json {lines} {path}") -> commands

Здесь функция glue::glue формирует строки вида -L 100,100 по шаблону. Затем, сгруппированные по пути к файлу, они склеиваются воедино с помощью str_flatten, а в итоге glue::glue_data из каждой строки таблицы делает текстовую строку‑команду.

Теперь выполняем команды. Тут нам пригодится map из пакета purrr. В качестве первого аргумента она принимает список, в качестве второго — функцию. Остальные аргументы просто передаются в указанную функцию. С pipe‑оператором конструкция выглядит удобно и изящно:

# можно написать и более «классический» вариант с анонимной функцией и без pipe-оператора cmds.output <- map(commands, \(x) system(x, intern=T))  # но с помощью синтаксического сахара всё становится гораздо прозрачнее: commands %>% map(system, intern=T) -> cmds.output

map и str_flatten пригодятся и для обработки результатов команд, а system возвращает вывод, разбитый по строкам.

cmds.output %>% map(\(x) x %>% str_flatten %>% parse_json) 

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

Линия идёт от даты появления фича-флага в коде до первого присвоения значения на проде. Цвет обозначает автора

Линия идёт от даты появления фича‑флага в коде до первого присвоения значения на проде. Цвет обозначает автора

Вот так несколькими несложными методами мы определили, какие фича‑флаги в проде висят долго в одном состоянии, а какие не включались вовсе — возможно, их потеряли или они вовсе не нужны?

Агрегация данных по мониторингу из разных мест

В нашем монолите несколько сот разных эндпоинтов с разной нагрузкой. При этом среднее время ответа также различается — некоторые работают быстрее, некоторые медленнее. Для того чтобы найти те, которые более всего влияют на общую нагрузку, нужно сопоставить данные по RPS и по времени ответа. Эти данные считаются с помощью разбора логов nginx. По историческим причинам конфигурация мониторинга для большого монолита обновляется вручную и выглядит как‑то так:

route_order_cancel:     And:         - Equals: {http_host: "example.com"}       - StartsWith: {request_url: "/orders/"}       - Or:           - EndsWith: {request_url: "/cancel"}           - EndsWith: {request_url: "/cancel/"}           - Contains: {request_url: "/cancel?"}           - Contains: {request_url: "/cancel/?"}

Соответственно, понять, каким именно эндпоинтам это соответствует, может быть довольно нетривиальной задачей, но только в том случае, если мы не используем ленивую функциональную природу R.

Мониторинг конфигурируется с помощью YAML‑файлов и точно так же, как и JSON, читается в иерархический список с помощью yaml::read_yaml. Список всех возможных эндпоинтов мы получим с помощью команды symfony debug:route --json. Осталось только сопоставить эти данные: определить, какое «человекопонятное» имя из конфигурации мониторинга соответствует тому или иному эндпоинту. Код, который выполняет такой, казалось бы, нетривиальный матчинг всего со всем, оказалось очень легко написать.

Ключевая функция — purrr::modify_tree, которая рекурсивно обходит список, включая вложенные списки, и может модифицировать элементы. Обходить будем следующим образом: каждый «лист» с предикатом будем сравнивать с таблицей роутов и заменять его на булев вектор, в котором каждый элемент будет обозначать, соответствует ли роут с соответствующим номером этому предикату. Логические операции в таком случае будут простой свёрткой результатов вычислений предикатов.

Основная часть кода — это перевод предикатов в соответствующие выражения R. Привожу его в несколько упрощённом виде. Уверен, что это можно было бы написать ещё проще, но с текущей задачей он справился на отлично. Единственный очевидный недостаток такого подхода — все правила вычисляются для всех роутов, оттого оно работает несколько неторопливо (единицы секунд на сопоставление ~ тысячи роутов и нескольких сотен правил), но в контексте анализа это совершенно несущественная задержка.

Код
monitoring_rule_exec <- function(rule, routes) {   # обрабатывает конкретный предикат   handleUrl <- function(clause, fun) {     fun(clause[[1]]) %>% replace_na(FALSE) # исполняем функцию и заменяем NA на FALSE   }      handlers <- list(     Equals = function(clause) {       field <- names(clause)[[1]]       expected <- clause[[1]]         # поскольку symfony выдаёт маршруты в виде регулярок, то мы должны проверить,       # соответствует ли заданный URL ему       switch(field,              http_host=stri_detect_regex(expected, routes$hostRegex),              request_url=stri_detect_regex(expected, routes$pathRegex),              request_method=expected == routes$method       ) %>% replace_na(FALSE)     },          StartsWith = function(clause) {       handleUrl(clause, \(x) stri_startswith(routes$path, fixed=x))     },          EndsWith = function(clause) {       handleUrl(clause, \(x) stri_endswith(routes$path, fixed=x))     },          Contains = function(clause) {       handleUrl(clause, \(x) stri_detect(routes$path, fixed=x))     },            Or = function(clause) {       purrr::reduce(clause, `|`)     },          And = function(clause) {       purrr::reduce(clause, `&`)     },          Not = function(clause) {       !clause[[1]]     }    )      rule %>% modify_tree(post = \(el) {     h <- handlers[[names(el)]]     el[[1]] %>% h %>% return # да, return — это тоже функция   }) }

Далее с этими данными можно эффективно отсеять роуты, которые нас не интересуют. По оставшимся можно выгрузить статистику из системы мониторинга по OpenAPI (пакет rapiclient позволяет сделать клиент по openapi‑описанию) и дальше искать интересующее.

На итоговом графике сразу видно, что нагрузка на большую часть эндпоинтов невелика, а вот api_order_integration_order имеет достаточно большое время ответа и при этом заметный RPS.

Разбор php-fpm.slow.log

Другая задача из разряда анализа производительности — анализ slow‑лога php‑fpm. Несмотря на то, что он не слишком‑то хорошо структурирован, средства tidyverse делают его разбор довольно простым делом. Сам лог выглядит примерно так:

Рандомный пример php-fpm.slow.log
[08-Dec-2024 16:56:48]  [pool www] pid 3863 script_filename = /code/index.php [0x0000000005fbc2d0] realpath() /code/includes/stream_wrappers.inc:377 [0x0000000005fbbdd0] getLocalPath()   /code/includes/stream_wrappers.inc:695 [0x00007ffff7ee1700] url_stat() unknown:0 [0x0000000005fbbb60] file_exists() /code/includes/common.inc:4945 [0x0000000005fbb058] drupal_aggregated_file_exists() /code/includes/common.inc:4994 [0x0000000005fb92c0] drupal_build_js_cache() /code/includes/common.inc:4429 [0x0000000005fb8d80] drupal_get_js() /code/includes/theme.inc:2703 [0x0000000005fb6f60] template_process_html() /code/includes/theme.inc:1125 [0x0000000005fb6010] theme() /code/includes/common.inc:5967 [0x0000000005fb5af0] drupal_render() /code/includes/common.inc:5814 [0x0000000005fb49b8] drupal_render_page() /code/includes/common.inc:2701 [0x0000000005fb4600] drupal_deliver_html_page() /code/includes/common.inc:2589 [0x0000000005fb3f50] drupal_deliver_page() /code/includes/menu.inc:532 [0x0000000005fb3d70] menu_execute_active_handler() /code/index.php:21  [08-Dec-2024 16:56:48]  [pool www] pid 3883 script_filename = /code/index.php [0x00000000027b95a0] realpath() /code/includes/stream_wrappers.inc:377 [0x00000000027b90a0] getLocalPath() /code/includes/stream_wrappers.inc:695 [0x00007ffff7ee1700] url_stat() unknown:0 [0x00000000027b8e30] file_exists() /code/includes/common.inc:4945 [0x00000000027b8328] drupal_aggregated_file_exists() /code/includes/common.inc:4994 [0x00000000027b6590] drupal_build_js_cache() /code/includes/common.inc:4429 [0x00000000027b6050] drupal_get_js() /code/includes/theme.inc:2703 [0x00000000027b4230] template_process_html() /code/includes/theme.inc:1125 [0x00000000027b32e0] theme() /code/includes/common.inc:5967 [0x00000000027b2dc0] drupal_render() /code/includes/common.inc:5814 [0x00000000027b1c88] drupal_render_page() /code/includes/common.inc:2701 [0x00000000027b18d0] drupal_deliver_html_page() /code/includes/common.inc:2589 [0x00000000027b1220] drupal_deliver_page() /code/includes/menu.inc:532 [0x00000000027b1040] menu_execute_active_handler() /code/index.php:21

Мыслить в парадигме tidy‑data здесь можно двумя способами:

  • Во‑первых, одно наблюдение — это одно зависание, то есть одна запись лога.

  • Во‑вторых, одно наблюдение — это одно конкретное место в коде, которое участвует в медленном запросе. Для исследования интереснее скорее второе, а первое будет лишь промежуточным представлением.

Записи разделяются двойным переводом строки, и мы их можем легко разделить на отдельные записи:

read_file(‘php-fpm.slow.log’) %>%    str_split_1("\n\n") %>% str_trim() -> entries

Далее отделяем «заголовок» записи и заодно прописываем синтетический id — он пригодится, чтобы отслеживать, к какому трейсу принадлежат его части. Функция separate_wider_regex позволяет разделить строку на столбцы по регулярному выражению. Синтаксис её довольно очевиден: мы составляем регулярку по частям, и именованные части превратятся в столбцы.

entries %>%    enframe(name='id') %>%   separate_wider_regex(value, c(     '\\[',     date='.+?', '\\]\\s+\\[pool ',     pool='[^\\]]+', '\\] pid ',     pid='\\d+',     '\\nscript_filename = ',     script='.*?', '\\n',      trace='(?s).*')) 

 Теперь у нас есть первое tidy-представление: каждая строка — это один факт зависания:

# A tibble: 2 × 6      id date                 pool  pid   script          trace                                                                                                        <int> <chr>                <chr> <chr> <chr>           <chr>                                                                                                      1     1 08-Dec-2014 16:56:48 www   3863  /code/index.php "[0x0000000005fbc2d0] realpath() /code/includes/stream_wrappers.inc:377\n[0x0000000005fbbdd0] getLocalPat… 2     2 08-Dec-2014 16:56:48 www   3883  /code/index.php "[0x00000000027b95a0] realpath() /code/includes/stream_wrappers.inc:377\n[0x00000000027b90a0] getLocalPat…

Теперь нужно развернуть каждый трейс. Для этого используем аналогичную технику:

… %>%  separate_longer_delim(trace, regex("\\n")) %>%   separate_wider_regex(trace, c(     '\\[', address=".*?", '\\]\\s+',     call = '.+?', ' ', file='.+?', ':', line='\\d+'   )) %>% type_convert %>%   mutate(date = lubridate::parse_date_time2(date, 'dbYHMS', tz='Europe/Moscow'))

Разделяем трейс по переводу строки с помощью separate_longer_delim — не «вширь» (wider), а «в длину» (longer), то есть все остальные строки при этом размножаются. Затем с помощью separate_wider_regex уже разделяем каждый трейс на столбцы дальше, автоматически конвертируем типы с помощью type_convert и вручную добиваем дату, с которой автоматика не справилась:

# A tibble: 28 × 9       id date                       pool    pid script          address            call                   file   line    <int> <dttm>                     <chr> <dbl> <chr>           <chr>              <chr>                  <chr> <dbl>  1     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fbc2d0 realpath()             /cod…   377  2     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fbbdd0 getLocalPath()         /cod…   695  3     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x00007ffff7ee1700 url_stat()             unkn…     0  4     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fbbb60 file_exists()          /cod…  4945  5     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fbb058 drupal_aggregated_fil… /cod…  4994  6     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fb92c0 drupal_build_js_cache… /cod…  4429  7     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fb8d80 drupal_get_js()        /cod…  2703  8     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fb6f60 template_process_html… /cod…  1125  9     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fb6010 theme()                /cod…  5967 10     1 2014-12-08 16:56:48.000000 www    3863 /code/index.php 0x0000000005fb5af0 drupal_render()        /cod…  5814 # ℹ 18 more rows # ℹ Use `print(n = ...)` to see more rows

Теперь с этими данными легко и удобно проводить практически любой анализ.

Полезные мелочи

Также очень удобно использовать R для обработки логов. Логи представляют собой множество JSON‑строк, в которых внутри могут быть также JSON, сериализованные в строку.

Это всё можно пропустить через jqr для деления на отдельные сообщения и превратить в датафрейм, а затем найти столбец с JSON‑сообщениями и распарсить его в столбцы. Делается это столь же просто:

… %>% mutate(message = map(message, parse_json)) %>% unnest_wider(message)

После некоторой привычки рутинные задачи тоже становится проще сделать через R. Например, проверить, какие директории прописаны у тех или иных EntityManager в конфигурации Doctrine в YAML. Строк там много, и глазами это смотреть довольно долго, и, что хуже, есть риск что‑то пропустить. С помощью clipboard() %>% yaml::yaml.load %>% … это становится не только быстрее, но и надёжнее.

На входе, в принципе, может быть всё что угодно — например, вывод strace. Я превратил его в датафрейм с одним столбцом (read_file %>% str_split_1("\n") %>% as_tibble_col), а затем распарсил при помощи регулярок (separate_wider_regex). Поскольку strace был сразу нескольких процессов, а также там были unfinished‑сообщения, их можно легко сгруппировать по процессам и с помощью функций lag/lead доклеить к началу сообщения его конец (а затем концы просто удалить). Далее я выделил интересующие системные вызовы и посмотрел статистику по частоте, времени и другим параметрам.

Заключение

Сейчас мне достаточно сложно представить свою жизнь без R/RStudio. Казалось бы, специализированный инструмент для аналитиков оказался практически незаменимым обычному разработчику в повседневной разработческой жизни. Практически в любой задаче есть элементы анализа данных, и эффективный инструмент превращает эту часть из скучной рутины в лёгкую и быструю работу.

  • Во‑первых, теперь я на всё смотрю, как на данные, и этот подход в принципе направляет мысль так, как я раньше и представить не мог. Это очень влияет на работу и повседневную жизнь.

  • Во‑вторых, любые данные легко и просто приводятся в tidy‑вид: упомянутые выше парсеры лога strace или php‑fpm.slow я написал буквально за 15 минут. Думаю, написание парсера на любом языке заняло бы примерно столько же времени, но с R на выходе я получаю данные, с которыми можно удобно проводить самый разный анализ.

  • В‑третьих, добавление данных в рабочую среду буквально методом копипаста (clipboard()%>% parse_json %>% …) открывает возможность джоинить всё и вся, вместо того чтобы сверять какие‑то вещи глазами. Единственное, что лучше сразу сохранять данные в какую-то переменную, потому что данные в буфере обмена не вечны.

Надеюсь, мой рассказ вдохновит вас тоже попробовать R/RStudio. А если у вас возникнут вопросы, то пишите их в комментариях — постараюсь ответить.


ссылка на оригинал статьи https://habr.com/ru/articles/873308/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *