JavaRush /Курси /Kotlin SELF /Метрики: таймінги та лічильники

Метрики: таймінги та лічильники

Kotlin SELF
Рівень 60 , Лекція 3
Відкрита

1. Вступ

Коли ви лише починаєте писати програми, здається, що логи — це і є діагностика. «Надрукую побільше тексту — і все зрозумію». Але за кілька тижнів стає видно інше: «побільше тексту» перетворюється на «побільше шуму». А відповідь на запитання «чому програма гальмує» з логів часом доводиться діставати майже як археологічну знахідку — пензликом і зі сльозами.

Метрики — це інший тип спостережуваності. Якщо логи розповідають історії (інколи драматичні), то метрики дають суху статистику. Умовно кажучи, логи відповідають на «що сталося з цією командою», а метрики — на «скільки таких команд було за весь час роботи програми і скільки часу вони займали».

Зручно тримати в голові просту табличку:

Що спостерігаємо Логи Метрики
Сенс «Події» «Вимірювання»
Типове запитання «Що пішло не так?» «Як часто?» / «Як довго?»
Приклад
ERROR Failed to parse amount
command.add.total = 17
command.add.tookMs = 3
Ризик багато шуму багато цифр без сенсу

І ще один важливий практичний момент: метрики не замінюють логи. Вони доповнюють їх — як градусник доповнює скаргу «мені погано». За одним числом не зрозумієш усієї історії. Зате можна швидко побачити, що «температура 39» — це вже не «подумаєш, трохи втомився».

2. Таймінги в консольному застосунку

Де в консольному застосунку використовувати таймінги

На старті легко впасти в іншу крайність: почати вимірювати час кожної функції, кожного if і кожного погляду на змінну. Це вже не метрики — це тривожність, записана в коді. У консольному застосунку нам корисні таймінги на межах операцій — там, де користувач справді ініціював дію: обробка команди, побудова звіту, імпорт/експорт, обчислення статистики.

Щоб було конкретніше, продовжимо наш навчальний CLI-проєкт (умовно назвімо його ExpenseTracker). У ньому користувач вводить команди на кшталт add, list, remove, total. Логи в нас уже підтримують рівні, контекст і виведення помилок. Тепер ми хочемо вимірювати, наприклад, скільки часу займає обробка кожної команди та як часто команди викликаються.

Схема процесу буде приблизно така:

flowchart LR
    U[Користувач вводить команду] --> C[CLI: parse/dispatch]
    C --> S[Сценарій/сервіс]
    S --> R[Репозиторій/сховище]
    C --> L[Logger: події]
    C --> M[Metrics: таймінги/лічильники]

Ми вимірюємо час навколо усього блоку обробки команди, а не навколо кожного кроку окремо. Так таймінг показує реальну «вартість» команди — ту, яку відчуває користувач.

measureTimeMillis{ ... }: найпростіший секундомір

З таймінгами є дві новини: хороша й чесна. Хороша — у Kotlin можна виміряти тривалість виконання блоку буквально однією функцією, і це виглядає охайно. Чесна — вимірювання часу в програмах не буває ідеальним. На нього впливають навантаження системи, JIT, «прогрів» і фонові процеси. Тому мілісекунди тут — практична оцінка, а не лабораторний експеримент.

У стандартній бібліотеці Kotlin є measureTimeMillis{ ... }. Вона виконує блок і повертає Long — скільки мілісекунд зайняло виконання. Для консольного застосунку такої точності більш ніж достатньо.

Міні-приклад «у вакуумі»:

import kotlin.system.measureTimeMillis

fun main() {
    val ms = measureTimeMillis {
        var sum = 0
        for (i in 1..100_000) sum += i
    }

    println("Тривалість: $ms мс") // Тривалість: <якесь число> мс
}

Зверніть увагу на стиль: ми не друкуємо час усередині блоку. Спочатку вимірюємо, потім використовуємо результат. Це дисципліна, яка дуже стане в пригоді: «виміряли → поклали в змінну → вирішили, що з нею робити».

Обгортаємо дію в таймер: timedResult(...)

Тепер зробімо крок від «прикладчика» до нашого проєкту. Нам потрібно вимірювати час різних дій (обробки команд), не копіюючи один і той самий шаблон val ms = measureTimeMillis{ ... } по всьому коду. Ідея проста: напишемо невелику функцію-обгортку, яка приймає лямбду action.

Так ви отримуєте єдиний стиль: де б не вимірювали час, код виглядає однаково. У результаті програму читати швидше (а ваш майбутній колега — перестає плакати).

Базова версія (якщо потрібен лише таймінг):

import kotlin.system.measureTimeMillis

fun timed(action: () -> Unit): Long {
    val tookMs = measureTimeMillis {
        action()
    }
    return tookMs
}

Але часто хочеться повернути не тільки час, а й результат: рядок для користувача, статус виконання тощо. У навчальному варіанті це можна зробити через Pair:

import kotlin.system.measureTimeMillis

fun <T> timedResult(action: () -> T): Pair<T, Long> {
    lateinit var result: T
    val ms = measureTimeMillis {
        result = action()
    }
    return result to ms
}

Так, тут використано lateinit для локальної змінної — виглядає трохи хитро. Але для маленької утиліти це допустимо. Ми гарантуємо, що action() буде викликано рівно один раз і присвоїть значення.

3. Лічильники та імена метрик

Лічильники: MutableMap<String, Int> як кишенькова статистика

З таймінгами розібралися: вони відповідають на «як довго». Тепер відповімо на «як часто». Найпростішою формою лічильників буде MutableMap<String, Int>, де ключ — імʼя метрики, а значення — скільки разів подія сталася.

Ми вже вміємо працювати з Map і MutableMap: у мапі ключі унікальні, а значення читається за ключем. Це рівно те, що потрібно для лічильників. Kotlin прямо підкреслює: Map зберігає пари «ключ-значення», а MutableMap дозволяє оновлювати значення за ключем.

Зробімо невеликий клас Metrics, щоб лічильники не «розмазалися» по всьому проєкту:

class Metrics {
    private val counters: MutableMap<String, Int> = mutableMapOf()

    fun inc(key: String) {
        val prev = counters[key] ?: 0
        counters[key] = prev + 1
    }

    fun get(key: String): Int = counters[key] ?: 0
}

У цій реалізації використано підхід «прочитали → якщо null, то 0 → збільшили → записали». Його застосовують повсюдно, коли будують лічильники за ключем у Map. Kotlin показує: значення за ключем читається як map["key"], а оновлення можна робити як map["one"] = 11.

Імена метрик: чому "command.total" краще, ніж totalCommandCounter123

Майже всі проблеми з метриками в реальних проєктах починаються не з коду, а з імен. Якщо назви не уніфіковані, лічильники швидко перетворюються на звалище: addCount, AddCounter, commands_add_total, add.total — і все це про одне й те саме. Тож навіть у навчальному проєкті варто одразу звикати до простої домовленості.

Ми виберемо стиль «шлях через крапку»:

  • "command.total" — скільки команд оброблено загалом
  • "command.add.total" — скільки разів викликали add
  • "command.list.total" — скільки разів викликали list
  • "command.error.total" — скільки разів команда завершилася помилкою

Це зручно, бо ключі візуально групуються: ви сортуєте рядки — і одразу бачите «родини». А ще це зручно тим, що в майбутньому такі ключі легко перетворюються на «нормальні» метрики в Prometheus/Graphite/тощо (сьогодні ми туди не йдемо, але звичка залишиться).

4. Метрики в обробці команд

Вбудовуємо метрики в обробку команд

Тепер найцікавіше: додамо метрики туди, де їм саме місце, — на рівень обробки команд. Уявімо спрощений обробник, який отримує рядок від користувача, нормалізує його, обирає команду й виконує дію.

Зробимо дві речі:

  • збільшуємо лічильники до/після виконання,
  • вимірюємо час виконання й записуємо його в лог як поле контексту.

Контекст у нас — це Map<String, String>, і ми форматуємо його уніфіковано. До речі, якщо вам потрібно гарно склеїти щось із колекції в рядок, стандартна бібліотека радить joinToString().

Приклад (дуже компактний, щоб було видно ідею):

import kotlin.system.measureTimeMillis

fun handleCommand(
    command: String,
    logger: Logger,
    metrics: Metrics,
    action: () -> Unit
) {
    metrics.inc("command.total")
    metrics.inc("command.$command.total")

    val ms = measureTimeMillis { action() }

    logger.info(
        message = "Команду оброблено",
        ctx = mapOf("command" to command, "tookMs" to ms.toString())
    )
}

Зверніть увагу: handleCommand не знає, що саме робить команда всередині. Це важливо з погляду архітектури: метрики — це спостережуваність, «рамка» навколо дії, а не частина бізнес-логіки.

Лічильники помилок: метрика, яка рятує нерви

Помилки ми вже логуємо (зі stack trace і контекстом). Але корисно ще й рахувати їхню кількість. Причина проста й практична: інколи програма не падає, а просто «часто свариться». І тоді хочеться побачити це числом, не перегортаючи кілометри логів.

Додамо інкремент метрики "command.error.total" у catch. Не будемо ускладнювати: ловимо Exception, збільшуємо лічильник і логуємо помилку.

import kotlin.system.measureTimeMillis

fun handleCommandSafe(
    command: String,
    logger: Logger,
    metrics: Metrics,
    action: () -> Unit
) {
    val ms = measureTimeMillis {
        try {
            action()
        } catch (e: Exception) {
            metrics.inc("command.error.total")
            logger.error("Команда завершилася помилкою", ctx = mapOf("command" to command), e = e)
        }
    }

    logger.info(
        "Команду завершено",
        ctx = mapOf("command" to command, "tookMs" to ms.toString())
    )
}

Зауважте нюанс: тут таймінг включає і успішний шлях, і обробку помилки. Зазвичай це саме те, що нам потрібно. Ми вимірюємо «команду як досвід користувача», а не лише «успішну гілку».

5. Звіт за метриками та робота з Map

Міні-звіт за метриками: друкуємо за командою metrics

Дуже хочеться наприкінці кожної дії друкувати «метрики зараз такі-то». Але ми памʼятаємо: зайвий вивід швидко перетворюється на шум. У навчальному консольному застосунку хороший компроміс — зробити окрему команду, наприклад metrics, яка друкує поточні значення лічильників.

Для цього нам потрібно отримати «знімок» мапи лічильників. Повернемо read-only Map, щоб зовнішній код випадково не зламав внутрішній стан.

class Metrics {
    private val counters: MutableMap<String, Int> = mutableMapOf()

    fun inc(key: String) {
        val prev = counters[key] ?: 0
        counters[key] = prev + 1
    }

    fun snapshot(): Map<String, Int> = counters.toMap()
}

Далі перетворюємо Map на текст. Ми можемо пройтися по entries і склеїти рядки через joinToString(), який якраз призначений для «збирання читабельного рядка з елементів колекції».

fun formatMetrics(metrics: Metrics): String {
    val snap = metrics.snapshot()

    return snap.entries
        .sortedBy { it.key }
        .joinToString(separator = "\n") { (k, v) -> "$k = $v" }
}

І десь у CLI:

fun printMetrics(metrics: Metrics) {
    println(formatMetrics(metrics))
    // command.add.total = 2
    // command.error.total = 1
    // command.total = 3
}

Тут println — це саме користувацький вивід (за командою користувача). Логи при цьому продовжують жити своїм життям.

Нюанс: Map — не Collection

Коли ви активно використовуєте списки, може з’явитися відчуття, що «всі структури даних однакові». Але Map влаштована інакше: у неї немає «елементів без ключів», вона зберігає записи (entries). Kotlin окремо підкреслює, що Map<K, V> — це окремий вид колекції й надає специфічні операції: доступ до значень за ключем і роботу з ключами та значеннями.

Чому це важливо для метрик? Тому що метрики за змістом майже завжди — це «ключ → значення». Нам не потрібен List<Int>, де незрозуміло, що означає 17-й елемент. Нам потрібен Map<String, Int>, де сенс вбудовано в ключ: "command.add.total".

6. Типові помилки

Помилка № 1: вимірювати «все підряд», включно з внутрішніми частинами циклів.
Коли таймінг зʼявляється в кожній функції й навколо кожної маленької ділянки коду, ви отримуєте море цифр без відповіді на запитання «а що, власне, повільне?». У консольному застосунку майже завжди достатньо вимірювати межі операції: обробку однієї команди, побудову одного звіту, виконання одного сценарію.

Помилка № 2: змішувати метрики з бізнес-логікою так, що без метрик нічого не працює.
Метрики — це спостережуваність, а не функціональність. Якщо ви написали код так, що «спочатку порахуємо лічильник, інакше команда не виконається», ви випадково зробили лічильник частиною бізнес-інваріанта. Правильніше тримати метрики як рамку: вони не мають впливати на коректність результату.

Помилка № 3: хаотичні імена ключів у лічильниках.
Сьогодні ви пишете addCount, завтра AddCount, післязавтра "command.add.total", а потім дивуєтеся, що «статистика не сходиться». Зафіксуйте формат ключів заздалегідь (наприклад, "command.add.total") і дотримуйтеся його. Це нудно, але нудьга — одна з ознак хорошої інженерії.

Помилка № 4: перетворювати метрики на лог-спам.
Якщо на кожну команду друкувати пʼять рядків «поточне значення лічильників», це дуже швидко вбиває читабельність. Метрики або дозовано пишуться в контекст логів (наприклад, tookMs), або виводяться окремою командою (metrics). Якщо ви не можете швидко очима знайти важливі повідомлення, значить, ви вже програли битву з шумом.

Помилка № 5: «вірити мілісекундам як долі».
Таймінги в мілісекундах корисні, але вони залежать від навантаження системи й можуть коливатися. Якщо один запуск показав 2 мс, а інший — 9 мс, це ще не привід переписувати все на асемблер і йти в гори. Дивіться на тенденції та на великі значення, а не на «мікрошум».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ