1. Вступ
Коли ви лише починаєте писати програми, здається, що логи — це і є діагностика. «Надрукую побільше тексту — і все зрозумію». Але за кілька тижнів стає видно інше: «побільше тексту» перетворюється на «побільше шуму». А відповідь на запитання «чому програма гальмує» з логів часом доводиться діставати майже як археологічну знахідку — пензликом і зі сльозами.
Метрики — це інший тип спостережуваності. Якщо логи розповідають історії (інколи драматичні), то метрики дають суху статистику. Умовно кажучи, логи відповідають на «що сталося з цією командою», а метрики — на «скільки таких команд було за весь час роботи програми і скільки часу вони займали».
Зручно тримати в голові просту табличку:
| Що спостерігаємо | Логи | Метрики |
|---|---|---|
| Сенс | «Події» | «Вимірювання» |
| Типове запитання | «Що пішло не так?» | «Як часто?» / «Як довго?» |
| Приклад | |
|
| Ризик | багато шуму | багато цифр без сенсу |
І ще один важливий практичний момент: метрики не замінюють логи. Вони доповнюють їх — як градусник доповнює скаргу «мені погано». За одним числом не зрозумієш усієї історії. Зате можна швидко побачити, що «температура 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 мс, це ще не привід переписувати все на асемблер і йти в гори. Дивіться на тенденції та на великі значення, а не на «мікрошум».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ