Мониторинг доступности сервера с мобильного приложения
Реализуем отправку аналитики доступности API с помощью InfluxDB
Во многих проектах, где существует интеграция с собственными сервисами, встает вопрос о сборе аналитики доступности этого сервиса. Это можно реализовать несколькими путями. Наиболее доступным и быстром среди них будет использовать 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 можно закешировать заранее, чтобы ускорить доступ клиентов к данным.