DevOps

Телеметрия установки и анализ воронки обновлений

Введение

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

Концепция

Телеметрия установки и обновлений должна собирать минимально необходимый набор анонимизированных событий: успешное завершение установки (с указанием версии и платформы), ошибки установки (с типом ошибки, без персональных данных), факт и время перехода на новую версию относительно даты выпуска обновления (для построения «воронки» — какая доля аудитории обновилась через день, неделю, месяц после релиза). Эти данные обычно отправляются на простой HTTP-эндпоинт сбора событий — никакой специфичной для QtIFW инфраструктуры не требуется, достаточно той же модели событийного логирования, что и в статье 176 про аналитику использования справки, применённой к другому набору событий.

Пример кода: Телеметрия установки и

// installscript.qs — отправка события об успешной установке прямо из установщика
function Component()
{
    installer.installationFinished.connect(this, Component.prototype.onInstallationFinished);
}

Component.prototype.onInstallationFinished = function()
{
    if (installer.status === QInstaller.Success) {
        var version = installer.value("ProductVersion");
        var platform = systemInfo.productType;

        // QtIFW поддерживает выполнение системных команд из скрипта установки —
        // используем это для отправки минимального HTTP-запроса о завершении установки
        var process = new QProcess();
        var curlArgs = ["-X", "POST",
            "https://telemetry.mycompany.com/events/install_completed",
            "-d", JSON.stringify({version: version, platform: platform, timestamp: new Date().toISOString()})];
        process.start("curl", curlArgs);
        process.waitForFinished(5000);
    }
};
// installtelemetry.h — отправка событий об обновлении из самого приложения
#pragma once
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QJsonObject>
#include <QJsonDocument>
#include <QSettings>
#include <QCoreApplication>
#include <QDateTime>

class InstallTelemetry : public QObject
{
    Q_OBJECT
public:
    explicit InstallTelemetry(QObject *parent = nullptr) : QObject(parent) {}

    void reportIfVersionChanged()
    {
        QSettings settings;
        const QString previousVersion = settings.value("telemetry/lastReportedVersion").toString();
        const QString currentVersion = QCoreApplication::applicationVersion();

        if (previousVersion == currentVersion) {
            return; // версия не изменилась с момента последнего запуска — ничего не сообщаем
        }

        const bool isFirstRun = previousVersion.isEmpty();
        sendEvent(isFirstRun ? "install_first_run" : "update_completed",
                 {{"previousVersion", previousVersion}, {"newVersion", currentVersion}});

        settings.setValue("telemetry/lastReportedVersion", currentVersion);
    }

private:
    void sendEvent(const QString &eventType, const QJsonObject &extraData)
    {
        QJsonObject payload = extraData;
        payload["event"] = eventType;
        payload["timestamp"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
        payload["anonymousInstallId"] = anonymousInstallId();

        QNetworkRequest request(QUrl("https://telemetry.mycompany.com/events"));
        request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
        m_manager.post(request, QJsonDocument(payload).toJson());
    }

    QString anonymousInstallId()
    {
        // Случайный, не привязанный к личности пользователя идентификатор
        // конкретной установки — НЕ персональные данные, генерируется один раз
        QSettings settings;
        QString id = settings.value("telemetry/anonymousId").toString();
        if (id.isEmpty()) {
            id = QUuid::createUuid().toString(QUuid::WithoutBraces);
            settings.setValue("telemetry/anonymousId", id);
        }
        return id;
    }

    QNetworkAccessManager m_manager;
};

Пояснения к коду

JavaScript-скрипт установщика отправляет событие install_completed непосредственно из самого процесса установки через вызов внешней утилиты curl — поскольку QtIFW не предоставляет встроенного HTTP-клиента в своей скриптовой среде, делегирование этой задачи системному инструменту командной строки — практичное решение, хотя и зависящее от наличия curl в системе (что на современных Windows 10+/macOS/большинстве Linux дистрибутивов по умолчанию доступно, но не гарантировано универсально).

InstallTelemetry::reportIfVersionChanged() вызывается при каждом запуске приложения, но реально отправляет событие только когда обнаруживает изменение версии относительно последнего зафиксированного значения — различая install_first_run (совсем новая установка, previousVersion пуста) и update_completed (переход с одной версии на другую) даёт возможность строить отдельную воронку для новых установок и для процесса принятия обновлений существующими пользователями. anonymousInstallId() генерирует случайный, не связанный с личностью пользователя идентификатор конкретной установки — это позволяет отличать уникальные установки в агрегированной статистике без сбора каких-либо персональных данных.

Подводные камни

  • Сбор телеметрии без явного согласия пользователя или прозрачного объяснения, какие данные собираются и зачем — даже полностью анонимизированные данные об установке должны собираться с уважением к ожиданиям приватности пользователя; для многих юрисдикций и категорий продуктов нужно явное согласие (опт-ин) или, как минимум, чёткое раскрытие в политике конфиденциальности, доступной перед самой установкой, а не просто молчаливая отправка данных.
  • Отправка телеметрии непосредственно из скрипта установщика через внешний curl-процесс блокирует завершение процесса установки до истечения таймаута сетевого запроса (или его успешного завершения) — process.waitForFinished(5000) в примере ограничивает это пятью секундами, но даже такая задержка добавляет заметное, хотя и небольшое замедление к восприятию пользователем скорости завершения установки; для минимизации влияния стоит рассмотреть полностью неблокирующий, «fire-and-forget» вызов без ожидания завершения вовсе, принимая риск того, что часть таких событий может не дойти до сервера при быстром завершении установщика.
  • Отсутствие устойчивости к временной недоступности сервера телеметрии. Если telemetry.mycompany.com временно недоступен в момент попытки отправки события (а не только в момент установки, но и при обычном запуске приложения после обновления), событие просто теряется без какой-либо локальной очереди для повторной попытки отправки позже — для более полной картины (особенно важной именно для построения точной «воронки» обновлений) стоит реализовать локальное буферизирование неотправленных событий с повторной попыткой при следующем успешном сетевом соединении, а не безусловно терять каждое неудачно отправленное событие.
  • Интерпретация агрегированных метрик телеметрии без учёта смещения выборки (selection bias). Пользователи, которые в принципе не соглашаются на отправку телеметрии (если это опционально, как и должно быть согласно предыдущему пункту), систематически отсутствуют в собранных данных — выводы о «доле пользователей, перешедших на новую версию за неделю», основанные только на телеметрии от согласившихся пользователей, могут не отражать реальную картину для всей аудитории продукта в целом, и это ограничение стоит явно учитывать при принятии решений на основе таких агрегированных метрик.