← все задачи

Python · задача 9 из 10

Разобрать лог и собрать статистику

Средний 20 минут генераторыпотоковая обработкаагрегация

Условие

Дан лог доступа: в каждой строке время, метод, путь, код ответа и длительность. Посчитайте по каждому пути: число запросов, долю ошибок (код 5xx) и среднее время ответа. Файл может быть в несколько гигабайт.

Что требуется

  • Файл не помещается в память — читать построчно
  • Битые строки не должны ронять обработку
  • Результат отсортирован по числу запросов

Пример

2026-09-13T10:00:01 GET /api/users 200 0.042
2026-09-13T10:00:02 GET /api/users 500 1.500
2026-09-13T10:00:03 POST /api/orders 201 0.310

# /api/users: 2 запроса, 50% ошибок, среднее 0.771с
# /api/orders: 1 запрос, 0% ошибок, среднее 0.310с

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Формат строк стабильный или бывают варианты — можно ли рассчитывать на split?
  • Нужно ли группировать пути с параметрами: /api/users/42 и /api/users/7 — это один путь или разные?
  • Что делать с битой строкой: пропустить молча, посчитать или упасть?
Показать решение Скрыть решение

Решение

from collections import defaultdict


def parse_line(line):
    parts = line.split()
    if len(parts) != 5:
        return None

    _timestamp, _method, path, status, duration = parts
    try:
        return path, int(status), float(duration)
    except ValueError:
        return None


def collect_stats(lines):
    stats = defaultdict(lambda: {"count": 0, "errors": 0, "total_time": 0.0})
    skipped = 0

    for line in lines:                       # lines — файл, читается лениво
        parsed = parse_line(line)
        if parsed is None:
            skipped += 1
            continue

        path, status, duration = parsed
        row = stats[path]
        row["count"] += 1
        row["total_time"] += duration
        if status >= 500:
            row["errors"] += 1

    report = [
        {
            "path": path,
            "count": row["count"],
            "error_rate": row["errors"] / row["count"],
            "avg_time": row["total_time"] / row["count"],
        }
        for path, row in stats.items()
    ]
    report.sort(key=lambda item: -item["count"])

    return report, skipped


with open("access.log") as f:
    report, skipped = collect_stats(f)

Почему так

Почему функция принимает строки, а не путь к файлу

  • Так её можно тестировать списком строк, без временных файлов на диске
  • На вход годится что угодно итерируемое: файл, gzip-поток, ответ по сети, генератор
  • Открытие файла остаётся снаружи — там же, где решают, закрывать его и как

Почему считаем суммы, а не храним все длительности

  • Для среднего достаточно суммы и количества — держать список из миллиона чисел незачем
  • Память остаётся пропорциональной числу уникальных путей, а не числу строк
  • Важная оговорка: для p95 и медианы так уже не выйдет — нужен t-digest или приблизительные квантили; об этом стоит сказать самому

Почему битые строки считаем, а не глотаем

  • Молча пропускать — значит однажды построить отчёт по трём строкам из миллиона и не заметить
  • Счётчик skipped наружу превращает «данные странные» в измеримый факт
  • Ронять обработку на одной кривой строке тоже нельзя: гигабайтный файл не дочитается из-за одного мусора

Что спросят дальше

  • Спросят про пути с id: покажите нормализацию — замену цифровых сегментов на {id} регуляркой
  • Спросят про распараллеливание: файл делится на куски по смещениям, агрегаты складываются — но сначала убедитесь, что узкое место в CPU, а не в диске
  • Спросят про p95 — честный ответ «нужны все значения или приблизительный алгоритм» лучше выдуманного

Следующая задача

Ограничитель частоты вызовов — Rate limiter руками: проверяют алгоритм, работу со временем и понимание гонок.