Не просто счётчик: модуль metric превращает Angie в мощную аналитическую платформу

Мы привыкли, что веб-сервер - это чёрный ящик, который просто гонит трафик. Стандартные метрики Angie представлены широким спектром. Но что, если нам надо еще больше? Что если прямо на уровне сервера, без изменения кода приложения, можно в реальном времени видеть, что именно клиенты добавляют в корзину, строить гистограммы времени ответа бэкендов или подсчитать буквально что угодно и делать это с производительностью атомарных операций в памяти? Сегодня разбираем мощнейший модуль metric в Angie.

Введение: Модуль metric - это просто

В предыдущей статье Многогранный мониторинг Angie мы разобрали, насколько удобно и просто можно настроить мониторинг. Однако, пользователям необходимо получать больше информации. Новый модуль metric открывает перед нами новые возможности.

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

Чтобы не закружилась голова, пойдем от простого к сложному. Вот простой пример: мы хотим знать, какими браузерами к нам ходят чаще. Мы просто посчитаем все запросы по заголовку User-Agent, который сохраняется в переменной $http_user_agent.

Переменная $http_<имя>, где последняя часть имени переменной соответствует имени заголовка запроса, приведенному к нижнему регистру, с заменой символов тире на символы подчеркивания, заполняется при поступлении запроса.

Для тех, кто уже пользуются Angie, объяснять что такое API нет необходимости. Мы просто включим его в конфиге:

location /api/ {
  api     /status/;
  access_log off;
}

Для нового читателя рекомендую к прочтению мою предыдущую статью про метрики - Многогранный мониторинг Angie, форка веб-сервера nginx

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

http {
    metric_zone browsers:1m count;
    metric browsers $http_user_agent on=request;
#.......
}

Выполнив запрос к серверу,

curl 127.0.0.1 --user-agent "Firefox"

в настроенном ранее API в новой ветке metric_zones, мы увидим простой счетчик:

{
    "http": {
       "metric_zones": {
           "browsers": {
               "discarded": 0,
               "metrics": {
                   "Firefox": 1
               }
           }
       }
    }
}

Теперь метрика Firefox будет тикать каждый раз когда к нам придет запрос с заголовком User-Agent: Firefox

Усложним задачу. Кажется, что сайт летает, но пользователи из Далекой страны жалуются на тормоза. Метрика с гео все расставит по местам. Если мы добавим к метрике $request_time, то сможем посчитать уже среднее значение по ключу. Например, ключ будет значением переменной $geoip2_country_iso_code из geoip2:

http {
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {
    auto_reload 1h;
    $geoip2_country_iso_code country iso_code;
}
    metric_zone geo:1m average mean;
#.......
   location / {
    #.......
    metric geo $geoip2_country_iso_code=$request_time on=end;
    #.......
   }
#.......
}

И в API мы сможем увидеть уже среднее значение по странам, что-то вроде:

{
  "discarded": 0,
  "metrics": {
    "ZA": 4.266,
    "US": 2.1193750000000004,
    "RU": 0.844
  }
}

Здесь пытливый читатель может резонно спросить: что, если я хочу смотреть и количество запросов и время сразу. Что же мне, описывать кучу зон? Ответ - нет. Всего лишь надо настроить комплексную зону metric_complex_zone:

http {
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {
    auto_reload 1h;
    $geoip2_country_iso_code country iso_code;
}
    metric_complex_zone geo:1m expire=on discard_key="old" {
    avg_time                average mean;
    total                   count;
    }
#.......
   location / {
    #.......
    metric geo $geoip2_country_iso_code=$request_time on=end;
    #.......
   }
#.......
}

И — вуаля! — получили что хотели:

"metrics": {
  "US": {
      "avg_time": 2.483,
      "total": 3
  },
  "RU": {
      "avg_time": 0.707,
      "total": 2
  },
  "ZA": {
      "avg_time": 3.2255,
      "total": 2
  }
}

Но не так уж и сложно

Браузеры и запросы - это все интересно. Что, если мы встанем на место пользователя нашим ресурсом и задумаемся, насколько наш ресурс выглядит хорошо со стороны? Можно ли это выразить как-то в цифрах? Оказывается, можно. Есть такой индекс удовлетворённости пользователей - APDEX.

Это международный стандарт измерения производительност�� приложений, ориентированный на удовлетворённость пользователей. Он превращает разброс времени отклика системы в единый числовой индекс качества работы с точки зрения пользователя.

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

Давайте посчитаем насколько быстро открывается главная страничка нашего сайта. Для этого мы возьмем переменную $request_time, так как мы смотрим на скорость работы с точки зрения пользователя. Будем считать, что клиентский запрос обработан хорошо, если время ответа не более 2-х секунд. Согласно спецификации открытого стандарта APDEX (п.4.1), удовлетворительное время должно быть 4 хорошего времени, то есть, в нашем случае не более 8 секунд. Все остальное - уже плохо. Для наших подсчетов нам подойдет гистограмма.

Добавим зону:

http {
 #.......
    metric_zone curapdex:1m histogram 2 8 inf;

И metric с уже определенным ключом:

location / {
 #.......
 metric curapdex curapdex=$request_time;
 #.......
}

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

Теперь мы готовы посчитать APDEX по формуле (0.5 * удовлетворительно + хорошо) / (общее количество запросов), для этого преобразуем нашу внутреннюю структуру данных в родной для Prometheus формат на лету с помощью шаблона:

http {
#.......
# Описание шаблона Prometheus для метрики "apdex"
prometheus_template apdex {
    'angie_apdex_curapdex_stats{le="$1"}'       $p8s_value
                                                path=~^/http/metric_zones/curapdex/metrics/curapdex/(.+)$
                                                type=histogram;
}
#.......
   # Prometheus metrics_path: /prometheus/apdex
   location /prometheus/apdex {
    prometheus apdex;
    access_log off;
}
#.......

Добавим новый target в конфиг Prometheus:

- job_name: "angie_apdex_metric"
scheme: http
metrics_path: /prometheus/apdex
static_configs:
  - targets: ['localhost:80']

А в самом Prometheus выполним расчет готовой метрики APDEX по формуле (sum(angie_apdex_curapdex_stats{le="2"}) + sum(angie_apdex_curapdex_stats{le="8"})) / 2 / sum(angie_apdex_curapdex_stats{le="inf"})

Теперь мы можем глядя на график решить, стоит ли уделять внимание вопросу повышения производительности. APDEX должен неудержимо стремиться к 1.

Даже если сложно, то возможно

Вообразим типичный интернет-магазин. Вам как разработчику или DevOps-инженеру нужно знать, как ведут себя бэкенды. Не просто «среднее время ответа», а полное распределение задержек по каждому upstream-серверу, чтобы выявить «болото». Как собрать эти метрики? Классические пути - лезть в логи приложения (высокая латентность, нагрузка на диск), встраивать SDK в код (сложность, дополнительные зависимости) или ставить sidecar-агенты (оверхэд).

С другой стороны, для бизнес процесса необходимо еще знать "какие товары самые популярные", например, сколько штук определенного товара было добавлено за последний час.

У нас есть какой-то крутой игрушечный онлайн-магазин с каталогом крутых товаров на порту 80 и тремя бэкенд-серверами (8080, 8081, 8082). Клиент через POST-запрос отправляет в корзину массив товаров с id и quantity. Наша цель - считать не просто факты добавления, а общее количество штук по интересующим нас товарам.

Разбор конфига:

Создаём зону metric_complex_zone с именем awesome_customs. В ней мы будем хранить гистограмму размера заказов, сумму, счётчик и среднее значение для каждого товара.

metric_complex_zone awesome_customs:1m discard_key="other" {
    stats    histogram 64 256 1024 4096 16384 +Inf;
    sum      gauge;
    count    count;
    avg_size average exp;
}

Определяем переменные, которые будут заполняться далее:

server {
listen  80;
  set $product_name '';
  set $product_quantity '';
#.......
}

Замечу, что здесь мы будем считать метрику на момент запроса от клиента (on=request). Т.е. еще до того как запрос будет обработан бэкэндом. Иногда такое поведение необходимо, чтобы узнавать о возможных проблемах еще до их появления.

Для того чтобы получить доступ к телу запроса на такой ранней стадии, будем использовать LUA.

На мгновение отвлечемся. Резонный вопрос: скажется ли на общей производительности использование модуля metric? Ответ: нет, но. Важно понимать, каким образом вычисляются переменные, используемые модулем. Сам по себе модуль не дает дополнительной нагрузки на CPU, однако сложные вычисления переменных могут внести вклад в утилизацию процессора.

В интересующем нас location включаем lua_need_request_body on; , парсим тело, заполняем переменные и директивой metric мы говорим Angie, что нужно подсчитать метрику:

location /addToCart {
      lua_need_request_body on;

      access_by_lua '
          ngx.req.read_body()
          local body = ngx.req.get_body_data()
          
          if body then
              local id = tonumber(string.match(body, \'"id":(%d+)\'))
              local qty = tonumber(string.match(body, \'"quantity":(%d+)\'))
              
              if id == 516970094 then
                  ngx.var.product_name = "awesome_product"
              elseif id == 516970095 then
                  ngx.var.product_name = "other_product"
              elseif id == 516970096 then
                  ngx.var.product_name = "premium_item"
              end
              
              if qty then
                  ngx.var.product_quantity = tostring(qty)
              end
          end
      ';
#.......
  metric awesome_customs $product_name=$product_quantity on=request;
#.......
}

Я не стану детально описывать dummy бэкэнды, скажу просто, что все они одинаково отрисовывают страничку, но с рандомной задержкой (для получения более похожих на прод метрик) и отдельно обрабатывают запрос на location /addToCart:

Чтобы не тыцать по кнопачкам долго и муторно, запустим небольшой скрипт, который будет складывать в корзину товары рандомно:

while true; do
    ids=(516970094 516970095 516970096)
    quantity=$(( RANDOM % 300 + 1 ))

    random_index=$(( RANDOM % 3 ))
    selected_id=${ids[$random_index]}
    
    curl -s -X POST http://127.0.0.1/addToCart \
         -H "Content-Type: application/json" \
         -d '[{"id":'"$selected_id"',"quantity":'"$quantity"'}]'
    echo ""
    sleep 1
    clear
done

И посмотрим на наполнение зоны curl -s http://127.0.0.1/api/http/metric_zones/awesome_customs/

{
  "discarded": 0,
  "metrics": {
    "premium_item": {
      "stats": {
        "64": 42,
        "256": 139,
        "1024": 162,
        "4096": 162,
        "16384": 162,
        "+Inf": 162
      },
      "sum": 22625,
      "count": 162,
      "avg_size": 51.3921395219927
    },
    "other_product": {
      "stats": {
        "64": 36,
        "256": 162,
        "1024": 181,
        "4096": 181,
        "16384": 181,
        "+Inf": 181
      },
      "sum": 27182,
      "count": 181,
      "avg_size": 137.831010765459
    },
    "awesome_product": {
      "stats": {
        "64": 29,
        "256": 141,
        "1024": 173,
        "4096": 173,
        "16384": 173,
        "+Inf": 173
      },
      "sum": 27757,
      "count": 173,
      "avg_size": 125.604625279747
    }
  }
}

JSON-формат - это хорошо для отладки, но для production нужна интеграция с Prometheus.

# Описание шаблона Prometheus для метрики "awesome_customs"
prometheus_template awesome_customs {
    'angie_awesome_product_metric_stats{le="$1"}'  $p8s_value
                                                path=~^/http/metric_zones/awesome_customs/metrics/awesome_product/stats/(.+)$
                                                type=histogram;

    'angie_awesome_product_metric_sum'             $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/awesome_product/sum
                                                type=summary;

    'angie_awesome_product_metric_stats_count'     $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/awesome_product/count
                                                type=counter;

    'angie_awesome_product_metric_avg_size'        $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/awesome_product/avg_size
                                                type=gauge;

    'angie_other_product_metric_stats{le="$1"}'  $p8s_value
                                                path=~^/http/metric_zones/awesome_customs/metrics/other_product/stats/(.+)$
                                                type=histogram;

    'angie_other_product_metric_sum'             $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/other_product/sum
                                                type=summary;

    'angie_other_product_metric_stats_count'     $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/other_product/count
                                                type=counter;

    'angie_other_product_metric_avg_size'        $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/other_product/avg_size
                                                type=gauge;

    'angie_premium_item_metric_stats{le="$1"}'  $p8s_value
                                                path=~^/http/metric_zones/awesome_customs/metrics/premium_item/stats/(.+)$
                                                type=histogram;

    'angie_premium_item_metric_sum'             $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/premium_item/sum
                                                type=summary;

    'angie_premium_item_metric_stats_count'     $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/premium_item/count
                                                type=counter;

    'angie_premium_item_metric_avg_size'        $p8s_value
                                                path=/http/metric_zones/awesome_customs/metrics/premium_item/avg_size
                                                type=gauge;

}

Как уже делали ранее в данной статье, добавим еще один target в конфиг Prometheus, и метрики из Angie автоматически появятся в Prometheus.

- job_name: "awesome_customs_metric"
	scheme: http
	metrics_path: /prometheus/awesome_customs
	static_configs:
	- targets: ['localhost:80']

Для удобства запустим еще один скрипт, запрашивающий вывод метрик в формате p8s каждую секунду:

while true; do
    curl -s http://127.0.0.1/prometheus/awesome\_customs
    echo ""
    sleep 1
    clear
done

И наконец, мы увидим данные в привычном формате p8s

# Angie Prometheus template "awesome_customs"
# TYPE angie_awesome_product_metric_stats histogram
angie_awesome_product_metric_stats{le="64"} 66
angie_awesome_product_metric_stats{le="256"} 276
angie_awesome_product_metric_stats{le="1024"} 317
angie_awesome_product_metric_stats{le="4096"} 317
angie_awesome_product_metric_stats{le="16384"} 317
angie_awesome_product_metric_stats{le="+Inf"} 317
# TYPE angie_awesome_product_metric_sum summary
angie_awesome_product_metric_sum 47885
# TYPE angie_awesome_product_metric_stats_count counter
angie_awesome_product_metric_stats_count 317
# TYPE angie_awesome_product_metric_avg_size gauge
angie_awesome_product_metric_avg_size 237.88480352817184
# TYPE angie_other_product_metric_stats histogram
angie_other_product_metric_stats{le="64"} 67
angie_other_product_metric_stats{le="256"} 251
angie_other_product_metric_stats{le="1024"} 294
angie_other_product_metric_stats{le="4096"} 294
angie_other_product_metric_stats{le="16384"} 294
angie_other_product_metric_stats{le="+Inf"} 294
# TYPE angie_other_product_metric_sum summary
angie_other_product_metric_sum 44436
# TYPE angie_other_product_metric_stats_count counter
angie_other_product_metric_stats_count 294
# TYPE angie_other_product_metric_avg_size gauge
angie_other_product_metric_avg_size 72.03072242530414
# TYPE angie_premium_item_metric_stats histogram
angie_premium_item_metric_stats{le="64"} 63
angie_premium_item_metric_stats{le="256"} 261
angie_premium_item_metric_stats{le="1024"} 307
angie_premium_item_metric_stats{le="4096"} 307
angie_premium_item_metric_stats{le="16384"} 307
angie_premium_item_metric_stats{le="+Inf"} 307
# TYPE angie_premium_item_metric_sum summary
angie_premium_item_metric_sum 47677
# TYPE angie_premium_item_metric_stats_count counter
angie_premium_item_metric_stats_count 307
# TYPE angie_premium_item_metric_avg_size gauge
angie_premium_item_metric_avg_size 224.87369442985199

Любители красивых графиков могут впоследствии добавить в Grafana

Пока мы мониторим бизнес-логику, почему бы не приглядеться и к инфраструктуре?

Для вычисления нужных нам переменных добавим следующий каскад из map

map $r_upstream $last_upstream {
    default $upstream_addr;
    "~.*,\s*(.*)$" $1;
    "backend" "";
    '' '';
}
map $upstream_status $r_upstream {
    default $upstream_addr;
    "~.*502$" '';
}
map $upstream_response_time $last_response_time {
    default $upstream_response_time;
    "~.*,\s*(.*)$" $1;
    - "";
}

Дело в том, что при возникновении события proxy_next_upstream стандартные переменные $upstream_response_time и $upstream_addr будут содержать в себе времена и адреса от нескольких ответов, разделённых запятыми. Нас интересуют последние значения в переменных, так как это и есть время и адрес пира из блока upstream обработавшего запрос.

При возникновении 502, мы не заполняем данную метрику.

Добавим зону:

metric_complex_zone upstream_time:10m discard_key="other" {
    stats      histogram 0.5 1 3 5 10 20 50 inf;
    max_time   max;
    min_time   min;
    total_req  count;
}

И метрику:

metric upstream_time $last_upstream=$last_response_time on=end;

Теперь для каждого upstream-сервера мы собираем гистограмму времени ответа с детальными бакетами, а также максимальное, минимальное время и общее количество запросов.

Запрос к API покажет, какой бэкенд начал «провисать»: если у бэкэнда наблюдается отклонение от среднего значения времени ответа - пора бить тревогу.

curl -s http://127.0.0.1/api/http/metric_zones/upstream_time

{
  "discarded": 0,
  "metrics": {
    "127.0.0.1:8080": {
      "stats": {
        "0.5": 224,
        "1": 267,
        "3": 432,
        "5": 568,
        "10": 771,
        "20": 771,
        "50": 771,
        "inf": 771
      },
      "max_time": 7.475,
      "min_time": 0,
      "total_req": 771
    },
    "127.0.0.1:8081": {
      "stats": {
        "0.5": 610,
        "1": 770,
        "3": 770,
        "5": 770,
        "10": 770,
        "20": 770,
        "50": 770,
        "inf": 770
      },
      "max_time": 0.701,
      "min_time": 0,
      "total_req": 770
    },
    "127.0.0.1:8082": {
      "stats": {
        "0.5": 174,
        "1": 770,
        "3": 770,
        "5": 770,
        "10": 770,
        "20": 770,
        "50": 770,
        "inf": 770
      },
      "max_time": 0.901,
      "min_time": 0,
      "total_req": 770
    }
  }
}

Заключение

В основном коде я использовал случайные задержки ngx.sleep(delay_ms / 1000) перед ответом, и читатель сможет самостоятельно "покрутить" их, если сочтет такой пример интересным для тестирования. Например:

content_by_lua_block {
	-- Случайная задержка от 2с до 7с в мс
	local delay_ms = math.random(2000, 7000)
	ngx.log(ngx.INFO, "Adding delay: ", delay_ms, "ms")
	ngx.sleep(delay_ms / 1000)  -- ngx.sleep принимает секунды
Скрытый текст
user  angie;
worker_processes  auto;
worker_rlimit_nofile 65536;

error_log  /var/log/angie/error.log notice;
pid        /run/angie.pid;

load_module modules/ngx_http_geoip2_module.so;
load_module modules/ndk_http_module.so;
load_module modules/ngx_http_lua_module.so;

events {
    worker_connections  65536;
}

http {

geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {
    auto_reload 1h;
    $geoip2_country_iso_code country iso_code;
}

log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent us="$upstream_status" '
                    '"$http_referer" uag="$http_user_agent" addr="$upstream_addr" country="$geoip2_country_iso_code" '
                    'time="$upstream_response_time" curapdex="$metric_curapdex_key" l_addr="$last_upstream" l_time="$last_response_time"';
access_log /var/log/angie/access.log main;

    metric_zone browsers:1m count;

    metric_complex_zone geo:1m expire=on discard_key="old" {
        avg_time                average mean;
        total                   count;
    }

    metric_zone curapdex:1m histogram 2 8 inf;

    metric_complex_zone awesome_customs:1m discard_key="other" {
        stats    histogram 64 256 1024 4096 16384 +Inf;
        sum      gauge;
        count    count;
        avg_size average exp;
    }

    metric_complex_zone upstream_time:10m discard_key="other" {
        stats      histogram 0.5 1 3 5 10 20 50 inf;
        max_time   max;
        min_time   min;
        total_req  count;
    }

    # Описание шаблона Prometheus для метрики "awesome_customs"
    prometheus_template awesome_customs {
        'angie_awesome_product_metric_stats{le="$1"}'  $p8s_value
                                                    path=~^/http/metric_zones/awesome_customs/metrics/awesome_product/stats/(.+)$
                                                    type=histogram;

        'angie_awesome_product_metric_sum'             $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/awesome_product/sum
                                                    type=summary;

        'angie_awesome_product_metric_stats_count'     $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/awesome_product/count
                                                    type=counter;

        'angie_awesome_product_metric_avg_size'        $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/awesome_product/avg_size
                                                    type=gauge;

        'angie_other_product_metric_stats{le="$1"}'  $p8s_value
                                                    path=~^/http/metric_zones/awesome_customs/metrics/other_product/stats/(.+)$
                                                    type=histogram;

        'angie_other_product_metric_sum'             $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/other_product/sum
                                                    type=summary;

        'angie_other_product_metric_stats_count'     $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/other_product/count
                                                    type=counter;

        'angie_other_product_metric_avg_size'        $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/other_product/avg_size
                                                    type=gauge;

        'angie_premium_item_metric_stats{le="$1"}'  $p8s_value
                                                    path=~^/http/metric_zones/awesome_customs/metrics/premium_item/stats/(.+)$
                                                    type=histogram;

        'angie_premium_item_metric_sum'             $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/premium_item/sum
                                                    type=summary;

        'angie_premium_item_metric_stats_count'     $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/premium_item/count
                                                    type=counter;

        'angie_premium_item_metric_avg_size'        $p8s_value
                                                    path=/http/metric_zones/awesome_customs/metrics/premium_item/avg_size
                                                    type=gauge;

    }

    # Описание шаблона Prometheus для метрики "apdex"
    prometheus_template apdex {
        'angie_apdex_curapdex_stats{le="$1"}'       $p8s_value
                                                    path=~^/http/metric_zones/curapdex/metrics/curapdex/(.+)$
                                                    type=histogram;
    }


    upstream backend {
        zone backend 256k;
        server 127.0.0.1:8080;
        server 127.0.0.1:8081;
        server 127.0.0.1:8082;
    }

    map $r_upstream $last_upstream {
        default $upstream_addr;
        "~.*,\s*(.*)$" $1;
        "backend" "";
        '' '';
    }
    map $upstream_status $r_upstream {
        default $upstream_addr;
        "~.*502$" '';
    }

    map $upstream_response_time $last_response_time {
        default $upstream_response_time;
        "~.*,\s*(.*)$" $1;
        - "";
    }

    server {
      listen  80;
        set $product_name '';
        set $product_quantity '';

      location / {
        proxy_pass http://backend;
        proxy_set_header x-product-name $product_name;
        proxy_read_timeout 10s;
        metric upstream_time $last_upstream=$last_response_time on=end;
        metric browsers $http_user_agent=$request_time on=end;
        metric geo $geoip2_country_iso_code=$request_time on=end;
        metric curapdex curapdex=$request_time;
      }

      location /addToCart {
            lua_need_request_body on;

            access_by_lua '
                ngx.req.read_body()
                local body = ngx.req.get_body_data()
                
                if body then
                    local id = tonumber(string.match(body, \'"id":(%d+)\'))
                    local qty = tonumber(string.match(body, \'"quantity":(%d+)\'))
                    
                    if id == 516970094 then
                        ngx.var.product_name = "awesome_product"
                    elseif id == 516970095 then
                        ngx.var.product_name = "other_product"
                    elseif id == 516970096 then
                        ngx.var.product_name = "premium_item"
                    end
                    
                    if qty then
                        ngx.var.product_quantity = tostring(qty)
                    end
                end
            ';

        proxy_pass http://backend;
        proxy_set_header x-product-name $product_name;
        metric awesome_customs $product_name=$product_quantity on=request;
        metric upstream_time $last_upstream=$last_response_time on=end;
      }

      location /api/ {
        api     /status/;
        access_log off;
      }
        # Prometheus metrics_path: /prometheus/awesome_customs
        location /prometheus/awesome_customs {
            prometheus awesome_customs;
            access_log off;
        }
        # Prometheus metrics_path: /prometheus/apdex
        location /prometheus/apdex {
            prometheus apdex;
            access_log off;
        }
    }



# Раздел с dummy бэкэндами

    server {
    listen  8080;

        location / {
          default_type 'text/html';
          
          content_by_lua_block {
            -- Случайная задержка от 2с до 7с в мс
            local delay_ms = math.random(2000, 7000)
            ngx.log(ngx.INFO, "Adding delay: ", delay_ms, "ms")
            ngx.sleep(delay_ms / 1000)  -- ngx.sleep принимает секунды

              ngx.say([[
              <!DOCTYPE html>
              <html>
              <body>
                  <h2>Shop Products</h2>
                  <ul>
              ]])
              
              local ids = {516970094, 516970095, 516970096}
              local names = {"Awesome Product", "Other Product", "Premium Item"}
              
              for i = 1, 3 do
                  ngx.say(string.format([[
                      <li>
                          <strong>%s</strong> (ID: %d)<br>
                          Quantity: <input type="number" id="qty-%d" value="1" min="1" style="width:60px;">
                          <button onclick="postToCart(%d)">
                              Add to Cart
                          </button>
                          <pre style="background:#eee;padding:5px;margin-top:5px;">
      POST /addToCart
      Content-Type: application/json

      [{"id":%d,"quantity":<span id="display-qty-%d">1</span>}]</pre>
                      </li>
                  ]], names[i], ids[i], ids[i], ids[i], ids[i], ids[i]))
              end

              ngx.say([[
                  </ul>
                  <script>
                  function postToCart(id) {
                      const quantity = document.getElementById('qty-' + id).value;
                      fetch('/addToCart', {
                          method: 'POST',
                          headers: {'Content-Type': 'application/json'},
                          body: JSON.stringify([{id: id, quantity: parseInt(quantity)}])
                      }).then(r => alert('Product ' + id + ' added! Quantity: ' + quantity));
                      
                      document.getElementById('display-qty-' + id).textContent = quantity;
                  }
                  
                  document.addEventListener('DOMContentLoaded', function() {
                      const inputs = document.querySelectorAll('input[type="number"]');
                      inputs.forEach(input => {
                          const id = input.id.replace('qty-', '');
                          input.addEventListener('input', function() {
                              document.getElementById('display-qty-' + id).textContent = this.value;
                          });
                      });
                  });
                  </script>
              </body>
              </html>
              ]])
          }
          location /addToCart {
            default_type text/plain;
            return 200 "Thank you for requesting $http_x_product_name.\n";
            }
        }
    }
    server {
    listen  8081;

        location / {
          default_type 'text/html';
          
          content_by_lua_block {
            -- Случайная задержка от 0.5с до 700мс в мс
            local delay_ms = math.random(50, 700)
            ngx.log(ngx.INFO, "Adding delay: ", delay_ms, "ms")
            ngx.sleep(delay_ms / 1000)  -- ngx.sleep принимает секунды

              ngx.say([[
              <!DOCTYPE html>
              <html>
              <body>
                  <h2>Shop Products</h2>
                  <ul>
              ]])
              
              local ids = {516970094, 516970095, 516970096}
              local names = {"Awesome Product", "Other Product", "Premium Item"}
              
              for i = 1, 3 do
                  ngx.say(string.format([[
                      <li>
                          <strong>%s</strong> (ID: %d)<br>
                          Quantity: <input type="number" id="qty-%d" value="1" min="1" style="width:60px;">
                          <button onclick="postToCart(%d)">
                              Add to Cart
                          </button>
                          <pre style="background:#eee;padding:5px;margin-top:5px;">
      POST /addToCart
      Content-Type: application/json

      [{"id":%d,"quantity":<span id="display-qty-%d">1</span>}]</pre>
                      </li>
                  ]], names[i], ids[i], ids[i], ids[i], ids[i], ids[i]))
              end

              ngx.say([[
                  </ul>
                  <script>
                  function postToCart(id) {
                      const quantity = document.getElementById('qty-' + id).value;
                      fetch('/addToCart', {
                          method: 'POST',
                          headers: {'Content-Type': 'application/json'},
                          body: JSON.stringify([{id: id, quantity: parseInt(quantity)}])
                      }).then(r => alert('Product ' + id + ' added! Quantity: ' + quantity));
                      
                      document.getElementById('display-qty-' + id).textContent = quantity;
                  }
                  
                  document.addEventListener('DOMContentLoaded', function() {
                      const inputs = document.querySelectorAll('input[type="number"]');
                      inputs.forEach(input => {
                          const id = input.id.replace('qty-', '');
                          input.addEventListener('input', function() {
                              document.getElementById('display-qty-' + id).textContent = this.value;
                          });
                      });
                  });
                  </script>
              </body>
              </html>
              ]])
          }
          location /addToCart {
            default_type text/plain;
            return 200 "Thank you for requesting $http_x_product_name.\n";
            }
        }
    }
    server {
    listen  8082;

        location / {
          default_type 'text/html';
          
          content_by_lua_block {
            -- Случайная задержка от 100мс до 9с в мс
            local delay_ms = math.random(100, 9000)
            ngx.log(ngx.INFO, "Adding delay: ", delay_ms, "ms")
            ngx.sleep(delay_ms / 1000)  -- ngx.sleep принимает секунды

              ngx.say([[
              <!DOCTYPE html>
              <html>
              <body>
                  <h2>Shop Products</h2>
                  <ul>
              ]])
              
              local ids = {516970094, 516970095, 516970096}
              local names = {"Awesome Product", "Other Product", "Premium Item"}
              
              for i = 1, 3 do
                  ngx.say(string.format([[
                      <li>
                          <strong>%s</strong> (ID: %d)<br>
                          Quantity: <input type="number" id="qty-%d" value="1" min="1" style="width:60px;">
                          <button onclick="postToCart(%d)">
                              Add to Cart
                          </button>
                          <pre style="background:#eee;padding:5px;margin-top:5px;">
      POST /addToCart
      Content-Type: application/json

      [{"id":%d,"quantity":<span id="display-qty-%d">1</span>}]</pre>
                      </li>
                  ]], names[i], ids[i], ids[i], ids[i], ids[i], ids[i]))
              end

              ngx.say([[
                  </ul>
                  <script>
                  function postToCart(id) {
                      const quantity = document.getElementById('qty-' + id).value;
                      fetch('/addToCart', {
                          method: 'POST',
                          headers: {'Content-Type': 'application/json'},
                          body: JSON.stringify([{id: id, quantity: parseInt(quantity)}])
                      }).then(r => alert('Product ' + id + ' added! Quantity: ' + quantity));
                      
                      document.getElementById('display-qty-' + id).textContent = quantity;
                  }
                  
                  document.addEventListener('DOMContentLoaded', function() {
                      const inputs = document.querySelectorAll('input[type="number"]');
                      inputs.forEach(input => {
                          const id = input.id.replace('qty-', '');
                          input.addEventListener('input', function() {
                              document.getElementById('display-qty-' + id).textContent = this.value;
                          });
                      });
                  });
                  </script>
              </body>
              </html>
              ]])
          }
          location /addToCart {
            default_type text/plain;
            return 200 "Thank you for requesting $http_x_product_name.\n";
            }
        }
    }
}

Вывод: Почему это меняет правила игры 

Представленные примеры - лишь верхушка айсберга. Модуль metric в Angie стирает грань между инфраструктурным и бизнес-мониторингом, позволяя внедрить практически любую пользовательскую метрику, до которой только сможем добраться.
Хотите узнавать, из какого региона приходят самые дорогие лиды, не подключая сторонние аналитические системы? Или в реальном времени видеть просадки скорости для конкретной версии мобильного приложения? Достаточно добавить пару строк в конфиг. Дерзайте, попробуйте - всё проще, чем кажется.