Мониторинг доступности сервера с мобильного приложения

Во многих проектах, где существует интеграция с собственными сервисами, встает вопрос о сборе аналитики доступности этого сервиса. Это можно реализовать несколькими путями. Наиболее доступным и быстром среди них будет использовать Firebase с его Performance Monitoring. Подключив модуль в проект ваши запроса станут автоматически трекаться, плюс появится возможность добавить свои события время затраты на которые вам необходимо затрекать. Так что если вашей целью является просто мониторить задержку с различных регионов, то можно закрывать эту статью и идти прямиком в Firebase. Однако, если у вас есть необходимость получать какую то дополнительную информацию, например, чтобы трекать падения парсера с клиентской стороны или мониторинг ответов от сервера, то вполне возможно, что вариант описанный в этом статье, вам подойдет.

InfluxDB

InfluxDB - база данных которая собирает события на основе временных данных. Есть два варианта - Облако и open source решение. Так как для наших задач нам необходимо изолироваться он своих собственных серверов, то можно использовать облако. Бесплатная квота позволит нам держать данные месяц и смотреть на них в режиме онлайне, а так же строить графики, анализировать данные, и отправлять уже более постоянные хранилища, если это требуется.

Настройка

Процесс регистрации простой, нам необходимо зайти на сервиc https://www.influxdata.com/ создать bucket и сгенерировать для него токен.

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

Содержимое БД

Интеграцию будем реализовывать в iOS на примере обычной сессии. На этом этапе важно продумать, какие данные мы собираемся слать и какие данные будут тегами и какие значениями. Так как InfluxDB мы можем строить запросы только на основе одного значения, то предлагаю поместить слеедующие значения: время, потраченное на запрос, или причину, почему этот запрос не прошел.

Структура отправки запроса в БД выглядит так

measurementName,tagKey=tagValue fieldKey="fieldValue" 1465839830100400200
--------------- --------------- --------------------- -------------------
     |               |                  |                    |
Measurement       Tag set           Field set            Timestamp

Первым идет Measurement - это будет названием так называемой "таблицы", затем через запятую идут теги, потом значения и timestamp. Все части, кроме тегов, являются обязательными.

К тегам предлагаю отнести такие значения: названия модуля; откуда происходил запрос; хост; путь; локация пользователя (если она доступна) и пр.

Поле значений может быть нескольких типов:

  • Float - числа в формате float - IEEE-754
  • Integer - целочисленное значение, в конце советуют добавить i , но это не обзательно. Например, 12485903i
  • UInteger - беззнаковое целочисленное значение, пишем в конце u аналогично с обычным integer-ом
  • String - строка ограниченая размером в 64Кб. следует указывать ее в кавычках
  • Boolean - обычный булеан, возможные значения: t/f, T/F, true/false, True/False, TRUE/FALSE

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

private extension Dictionary where Key == String {
    func toInfluxLine(wrapString: Bool = true) -> String? {
        var fields: [String] = []
        for (key, value) in self {
            let converted: String
            switch value {
            case let float as Float: converted = "\(float)"
            case let double as Double: converted = "\(double)"
            case let integer as Int: converted = "\(integer)i"
            case let uInteger as UInt: converted = "\(uInteger)u"
            case let string as String: converted = wrapString ? "\"\(string)\"" : string
            case let bool as Bool: converted = bool ? "true" : "false"
            default: continue
            }
            fields.append("\(key)=\(converted)")
        }
        guard !fields.isEmpty else { return nil }
        return fields.joined(separator: ",")
    }
}

Сбор данных

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

final class NetworkManager: NSObject, URLSessionDataDelegate {
    let session = URLSession(configuration: .default)
    
    func makeRequest() async throws {
        let url = URL(string: "https://www.google.com/")!
        let _ = try await URLSession.shared.data(from: url, delegate: self)
    }
    
    func trace(url: URL?, duration: TimeInterval) {
        guard let url = url else { return }
        print("Host:", url.host ?? "-", "Path:", url.relativePath, "Duration:", duration)
    }
    
    // MARK: - URLSessionDataDelegate
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
        trace(url: task.currentRequest?.url, duration: metrics.taskInterval.duration)
    }
}

Тестовый запрос может выглядеть так:

let manager = NetworkManager()
do {
    try await manager.makeRequest()
} catch {
    print("Error:", error.localizedDescription)
}

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

Отправка

Итак, у нас есть необходимые данные для отправки. Пора приступать к самой отправке. Для этого нам потребуется адрес облака который вы выбрали для хранения, токен который вы получили прежде и ID бакета. Отправку можно сделать двумя способами, отправка текстом или gzip. Здесь я покажу как отправляется текстом, запаковать далее, если данных много будет несложно. Напишем отдельный класс, который будет отвечать за отправку.

final class InfluxDbService {
    let host: String
    let bucketId: String
    let session: URLSession
    
    init(host: String, bucketId: String, token: String) {
        self.host = host
        self.bucketId = bucketId
        
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = [
            "Content-Type": "text/plain; charset=utf-8",
            "Accept": "application/json",
            "Authorization": "Token \(token)"
        ]
        session = URLSession(configuration: configuration)
    }
    
    public func addMetrics(url: URL, duration: TimeInterval) {
        let measurement = "APIHealth"
        var tags: [String: Any] = [:]
        tags["host"] = url.host
        tags["path"] = url.relativePath
        var fields: [String: Any] = [:]
        fields["duration"] = duration
        
        guard !fields.isEmpty else { return }
        
        var message = measurement
        if let line = tags.toInfluxLine(wrapString: false) { message += "," + line }
        if let line = fields.toInfluxLine() { message += " " + line }
        let timestamp = Int(1000 * Date().timeIntervalSince1970)
        message += " \(timestamp)"
        
        let url = URL(string: "\(host)/api/v1/write?bucket=\(bucketId)&precision=ms")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.httpBody = message.data(using: .utf8)
        session.dataTask(with: request).resume()
    }
}

Здесь мы можем видеть отправку нашей сформированной строки на адрес (host)/api/v1/write?bucket=(bucketId)&precision=ms . Где

  • host - адрес облака
  • bucketId - идентифкатор бакета
  • precision - размерность в которой мы отправили время

На данном этапе было бы нецелесообразно отправлять метрики каждый раз, как совершился запрос, но более эффективно было бы собрать их вместе и отправлять раз в определенный промежуток времени, например раз в пять секунд. Поэтому, предлагаю расширить функицонал класса и добавить Timer, который будет работать в debounce-режиме: т.е. каждый раз сбрасываться до тех пор пока не пройдет таймаут.

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

var metrics: [String] = [] { didSet { debounceTimer() } }
var timer: Timer?

func addMetrics(url: URL, duration: TimeInterval) {
    ...
    metrics.append(message)
}

Ниже представлена сама инициализация таймера. Интервал установлен в 5 секунд, который будет вызывать отправку метрик.

private func debounceTimer() {
    DispatchQueue.main.async { [metrics, weak self] in
        self?.timer?.invalidate()
        guard !metrics.isEmpty else { return }
        self?.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in self?.submitMetrics() }
    }
}

Соберем метрики в единое сообщение и отправим.

private func submitMetrics() {
    guard !metrics.isEmpty else { return }
    let message = metrics.joined(separator: "\n")
    metrics = []
    
    let url = URL(string: "\(host)/api/v1/write?bucket=\(bucketId)&precision=ms")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.httpBody = message.data(using: .utf8)
    session.dataTask(with: request).resume()
}

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

NotificationCenter.default.addObserver(
    forName: UIScene.willDeactivateNotification,
    object: nil,
    queue: nil) { [weak self] _ in self?.submitMetrics() }

Весь код класса получается таким:

final class InfluxDbService2 {
    let host: String
    let bucketId: String
    let session: URLSession
    var metrics: [String] = [] { didSet { debounceTimer() } }
    var timer: Timer?
    
    init(host: String, bucketId: String, token: String) {
        self.host = host
        self.bucketId = bucketId
        
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = [
            "Content-Type": "text/plain; charset=utf-8",
            "Accept": "application/json",
            "Authorization": "Token \(token)"
        ]
        session = URLSession(configuration: configuration)
        
        NotificationCenter.default.addObserver(
            forName: UIScene.willDeactivateNotification,
            object: nil,
            queue: nil) { [weak self] _ in self?.submitMetrics() }
    }
    
    func addMetrics(url: URL, duration: TimeInterval) {
        let measurement = "APIHealth"
        var tags: [String: Any] = [:]
        tags["host"] = url.host
        tags["path"] = url.relativePath
        var fields: [String: Any] = [:]
        fields["duration"] = duration
        
        guard !fields.isEmpty else { return }
        
        var message = measurement
        if let line = tags.toInfluxLine(wrapString: false) { message += "," + line }
        if let line = fields.toInfluxLine() { message += " " + line }
        let timestamp = Int(1000 * Date().timeIntervalSince1970)
        message += " \(timestamp)"
        
        metrics.append(message)
    }
    
    private func debounceTimer() {
        DispatchQueue.main.async { [metrics, weak self] in
            self?.timer?.invalidate()
            guard !metrics.isEmpty else { return }
            self?.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in self?.submitMetrics() }
        }
    }
    
    private func submitMetrics() {
        guard !metrics.isEmpty else { return }
        let message = metrics.joined(separator: "\n")
        metrics = []
        
        let url = URL(string: "\(host)/api/v1/write?bucket=\(bucketId)&precision=ms")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.httpBody = message.data(using: .utf8)
        session.dataTask(with: request).resume()
    }
}

Анализ

Теперь у нас есть сбор аналитики и их отправка. Можно приступать к составлению дашбордов и графиков. Вернемся в панель InfluxDb и попробуем создать дашборд.

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

from(bucket: "BucketName")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "APIHealth" and r["_field"] == "duration")
  |> group(columns: ["host"])
  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)
  |> yield(name: "mean")

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

Вывод

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