1 /*
2 
3   Copyright (c) 2014, Michael Tesch
4   All rights reserved.
5 
6   Redistribution and use in source and binary forms, with or without
7   modification, are permitted provided that the following conditions are met:
8 
9   * Redistributions of source code must retain the above copyright notice, this
10     list of conditions and the following disclaimer.
11 
12   * Redistributions in binary form must reproduce the above copyright notice,
13     this list of conditions and the following disclaimer in the documentation
14     and/or other materials provided with the distribution.
15 
16   * Neither the name of Michael Tesch nor the names of other
17     contributors may be used to endorse or promote products derived from
18     this software without specific prior written permission.
19 
20   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21   AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22   IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23   DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24   FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25   DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26   SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27   CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28   OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29   OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 
31   https://github.com/tesch1/qt-google-analytics-collector
32 
33   to enable debugging messages, '#define GANALYTICS_DEBUG 1' before including this file
34   to get super verbose debugging, '#define GANALYTICS_DEBUG 2'
35 
36   To build GAnalytics with QtQuick application (QGuiApplication) instead of Desktop,
37   define GANALYTICS_QTQUICK in your .pro file like this: 'DEFINES += GANALYTICS_QTQUICK',
38   or in cmake project: 'add_definitions(-DGANALYTICS_QTQUICK)'
39 */
40 #include <QCoreApplication>
41 #include <QSettings>
42 
43 #if defined(GANALYTICS_QTQUICK)
44 #include <QGuiApplication>
45 #include <QScreen>
46 #else
47 #ifdef QT_GUI_LIB
48 #include <QApplication>
49 #include <QDesktopWidget>
50 #endif
51 #endif
52 
53 #include <QUuid>
54 #include <QNetworkAccessManager>
55 #include <QNetworkRequest>
56 #include <QNetworkReply>
57 #include <QUrl>
58 #include <QDebug>
59 #include <QProcess>
60 #include <QList>
61 #include <QEventLoop>
62 
63 #if (QT_VERSION >= QT_VERSION_CHECK(5, 0, 0))
64 #include <QUrlQuery>
65 #define URL_QUERY QUrlQuery
66 #else
67 #define URL_QUERY QUrl
68 #endif
69 
70 #include "common.h"
71 
72 /*!
73  * send google tracking data according to
74  * https://developers.google.com/analytics/devguides/collection/protocol/v1/reference
75  */
76 
77 #ifndef GANALYTICS_DEBUG
78 #define GANALYTICS_DEBUG 0
79 #endif
80 
81 class GAnalytics : public QObject {
82   Q_OBJECT
Q_PROPERTY(bool isBusy READ isBusy NOTIFY busyChanged)83   Q_PROPERTY (bool isBusy READ isBusy NOTIFY busyChanged)
84 public:
85   GAnalytics(QCoreApplication * parent,
86              QString trackingID,
87              QString clientID = "",
88              bool useGET = false)
89     : QObject(parent), _qnam(this), _trackingID(trackingID), _clientID(clientID), _useGET(useGET), _isFail(false)
90     , _waitLoop()
91   {
92     if (parent) {
93       setAppName(parent->applicationName());
94       setAppVersion(parent->applicationVersion());
95 #ifdef QT_GUI_LIB
96       parent->dumpObjectTree();
97 #endif
98     }
99     if (!_clientID.size()) {
100       // load client id from settings
101       QSettings settings;
102       if (!settings.contains("GAnalytics-cid")) {
103         settings.setValue("GAnalytics-cid", QUuid::createUuid().toString());
104       }
105       _clientID = settings.value("GAnalytics-cid").toString();
106     }
107     connect(&_qnam, SIGNAL(finished(QNetworkReply *)), this, SLOT(replyFinished(QNetworkReply *)));
108 #if QT_VERSION >= 0x040800
109 #if GANALYTICS_DEBUG
110     if (!_qnam.networkAccessible())
111       qDebug() << "error: network inaccessible\n";
112 #endif
113 #endif
114   }
115 
~GAnalytics()116   ~GAnalytics() {
117     // this generally happens after the event-loop is done, so no more network processing
118 #if GANALYTICS_DEBUG
119     QList<QNetworkReply *> replies = _qnam.findChildren<QNetworkReply *>();
120     for (QList<QNetworkReply *>::iterator it = replies.begin(); it != replies.end(); it++) {
121       if ((*it)->isRunning())
122         qDebug() << "~GAnalytics, request still running: " << (*it)->url().toString() << ", aborting.";
123       //reply->deleteLater();
124     }
125     qWarning() << "~GAnalytics";
126 #endif
127   }
128 
129   // manual config of static fields
setClientID(QString clientID)130   void setClientID(QString clientID) { _clientID = clientID; }
setUserID(QString userID)131   void setUserID(QString userID) { _userID = userID; }
setUserIPAddr(QString userIPAddr)132   void setUserIPAddr(QString userIPAddr) { _userIPAddr = userIPAddr; }
setUserAgent(QString userAgent)133   void setUserAgent(QString userAgent) { _userAgent = userAgent; }
setAppName(QString appName)134   void setAppName(QString appName) { _appName = appName; }
setAppVersion(QString appVersion)135   void setAppVersion(QString appVersion) { _appVersion = appVersion; }
setScreenResolution(QString screenResolution)136   void setScreenResolution(QString screenResolution) { _screenResolution = screenResolution; }
setViewportSize(QString viewportSize)137   void setViewportSize(QString viewportSize) { _viewportSize = viewportSize; }
setUserLanguage(QString userLanguage)138   void setUserLanguage(QString userLanguage) { _userLanguage = userLanguage; }
getClientID()139   QString getClientID() const { return _clientID; }
getUserAgent()140   QString getUserAgent() const { return _userAgent; }
141 
142   //
143   // hit types
144   //
145   // - https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide
146   //
147 
148   // query processing in progress
isBusy()149   bool isBusy() const { return !_workingQueries.isEmpty(); }
150 
151 public Q_SLOTS:
152 
153   // pageview
sendPageview(QString docHostname,QString page,QString title)154   void sendPageview(QString docHostname, QString page, QString title) {
155     URL_QUERY params = build_metric("pageview");
156     params.addQueryItem("dh", docHostname);    // document hostname
157     params.addQueryItem("dp", page);            // page
158     params.addQueryItem("dt", title);           // title
159     send_metric(params);
160   }
161 
162   // event
163   void sendEvent(const QString& eventCategory,
164                  const QString& eventAction,
165                  const QString& eventLabel = QString(),
166                  int eventValue = 0) {
167     URL_QUERY params = build_metric("event");
168     if (_appName.size()) params.addQueryItem("an", _appName); // mobile event app tracking
169     if (_appVersion.size()) params.addQueryItem("av", _appVersion); // mobile event app tracking
170     if (eventCategory.size()) params.addQueryItem("ec", eventCategory);
171     if (eventAction.size()) params.addQueryItem("ea", eventAction);
172     if (eventLabel.size()) params.addQueryItem("el", eventLabel);
173     if (eventValue) params.addQueryItem("ev", QString::number(eventValue));
174     send_metric(params);
175   }
176 
177   // transaction
178   void sendTransaction(QString transactionID, QString transactionAffiliation = "" /*...todo...*/) {
179     URL_QUERY params = build_metric("transaction");
180     params.addQueryItem("ti", transactionID);
181     if (transactionAffiliation.size()) params.addQueryItem("ta", transactionAffiliation);
182     send_metric(params);
183   }
184 
185   // item
sendItem(QString itemName)186   void sendItem(QString itemName) {
187     URL_QUERY params = build_metric("item");
188     params.addQueryItem("in", itemName);
189     //if (appName.size()) params.addQueryItem("an", appName);
190     send_metric(params);
191   }
192 
193   // social
sendSocial(QString socialNetwork,QString socialAction,QString socialActionTarget)194   void sendSocial(QString socialNetwork, QString socialAction, QString socialActionTarget) {
195     URL_QUERY params = build_metric("social");
196     params.addQueryItem("sn", socialNetwork);
197     params.addQueryItem("sa", socialAction);
198     params.addQueryItem("st", socialActionTarget);
199     send_metric(params);
200   }
201 
202   // exception
203   void sendException(QString exceptionDescription, bool exceptionFatal = true) {
204     URL_QUERY params = build_metric("exception");
205     if (exceptionDescription.size()) params.addQueryItem("exd", exceptionDescription);
206     if (!exceptionFatal) params.addQueryItem("exf", "0");
207     send_metric(params);
208   }
209 
210   // timing
sendTiming()211   void sendTiming(/* todo */) {
212     URL_QUERY params = build_metric("timing");
213     //if (appName.size()) params.addQueryItem("an", appName);
214     send_metric(params);
215   }
216 
217   // screenview
218   void sendScreenview(const QString& screenName, const QString& appName = QString(), const QString& appVersion = QString()) {
219     URL_QUERY params = build_metric("screenview");
220     if (!appName.isEmpty()) params.addQueryItem("an", appName);
221     else if (!_appName.isEmpty()) params.addQueryItem("an", _appName);
222     if (!appVersion.isEmpty()) params.addQueryItem("av", appVersion);
223     else if (!_appVersion.isEmpty()) params.addQueryItem("av", _appVersion);
224     if (screenName.size()) params.addQueryItem("cd", screenName);
225     send_metric(params);
226   }
227 
228   // custom dimensions / metrics
229   // todo
230 
231   // experiment id / variant
232   // todo
233 
234   //void startSession();
startSession()235   void startSession() {
236     URL_QUERY params = build_metric("event");
237     if (_appName.size()) params.addQueryItem("an", _appName); // mobile event app tracking
238     if (_appVersion.size()) params.addQueryItem("av", _appVersion); // mobile event app tracking
239     params.addQueryItem("sc", "start");
240     params.addQueryItem("ec", "General");
241     params.addQueryItem("ea", "Start");
242     params.addQueryItem("ev", "0");
243     send_metric(params);
244   }
245 
246   // To ensure that query was sent before application quit, call waitForIdle()
endSession()247   void endSession() {
248     URL_QUERY params = build_metric("event");
249     if (_appName.size()) params.addQueryItem("an", _appName); // mobile event app tracking
250     if (_appVersion.size()) params.addQueryItem("av", _appVersion); // mobile event app tracking
251     params.addQueryItem("sc", "end");
252     params.addQueryItem("ec", "General");
253     params.addQueryItem("ea", "End");
254     params.addQueryItem("ev", "0");
255     send_metric(params);
256   }
257 
258   // Waiting for any network operations complete. This method can be used with endSession
259   // to ensure that query was completed before application was closed.
waitForIdle()260   void waitForIdle() {
261       if (_waitLoop) {
262           qCritical() << "Recursive call GAnalytics::waitForIdle";
263           return;
264       }
265 
266       QEventLoop loop;
267       _waitLoop = &loop;
268       loop.exec(QEventLoop::ExcludeUserInputEvents);
269       _waitLoop = 0;
270   }
271 
272 signals:
273   void busyChanged();
274 
275 public:
276 
generateUserAgentEtc()277   void generateUserAgentEtc() {
278     QString locale = QLocale::system().name().toLower().replace("_", "-");
279     QString os = Common::operatingSystemLong();
280     _userAgent = "Mozilla/5.0 (" + os + "; " + locale + ") GAnalytics/1.0 (Qt/" QT_VERSION_STR ")";
281     _userLanguage = locale;
282 #if defined(GANALYTICS_QTQUICK)
283     QScreen* screen = qApp->primaryScreen();
284     QString geom = QString::number(screen->geometry().width())
285             + "x" + QString::number(screen->geometry().height());
286     _screenResolution = geom;
287 #else
288 #ifdef QT_GUI_LIB
289     QString geom = QString::number(QApplication::desktop()->screenGeometry().width())
290       + "x" + QString::number(QApplication::desktop()->screenGeometry().height());
291     _screenResolution = geom;
292 #endif
293 #endif
294 #if GANALYTICS_DEBUG > 1
295     qDebug() << "User-Agent:" << _userAgent;
296     qDebug() << "Language:" << _userLanguage;
297     qDebug() << "Screen Resolution:" << _screenResolution;
298 #endif
299   }
300 
301 private Q_SLOTS:
302 
replyFinished(QNetworkReply * reply)303   void replyFinished(QNetworkReply * reply) {
304     //qDebug() << "reply:" << reply->readAll().toHex();
305     if (QNetworkReply::NoError != reply->error()) {
306       qCritical() << "replyFinished error: " << reply->errorString() << "\n";
307     }
308     else {
309       int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
310       //qDebug() << "response code: " << httpStatus;
311       if (httpStatus < 200 || httpStatus > 299) {
312 #if GANALYTICS_DEBUG
313         qDebug() << "response code: " << httpStatus;
314 #endif
315         _isFail = true;
316       }
317     }
318 
319     _workingQueries.removeAll(reply);
320     if (_workingQueries.isEmpty()) {
321       emit busyChanged();
322       if (_waitLoop)
323         _waitLoop->exit();
324     }
325 
326     reply->deleteLater();
327   }
328 
replyError(QNetworkReply::NetworkError code)329   void replyError(QNetworkReply::NetworkError code) {
330     qDebug() << "network error signal: " << code << "\n";
331   }
332 
333 private:
334   GAnalytics(const GAnalytics &); // disable copy const constructor
build_metric(const QString & hitType)335   URL_QUERY build_metric(const QString& hitType) const {
336     URL_QUERY params;
337     // required in v1
338     params.addQueryItem("v", "1" ); // version
339     params.addQueryItem("tid", _trackingID);
340     params.addQueryItem("cid", _clientID);
341     params.addQueryItem("t", hitType);
342     // optional
343     if (_userID.size())
344       params.addQueryItem("uid", _userID);
345     if (_userIPAddr.size())
346       params.addQueryItem("uip", _userIPAddr);
347     if (_screenResolution.size())
348       params.addQueryItem("sr", _screenResolution);
349     if (_viewportSize.size())
350       params.addQueryItem("vp", _viewportSize);
351     if (_userLanguage.size())
352       params.addQueryItem("ul", _userLanguage);
353     //if (_userAgent.size())
354     //  params.addQueryItem("ua", _userAgent);
355     return params;
356   }
357 
send_metric(const URL_QUERY & params)358   void send_metric(const URL_QUERY & params) {
359     // when google has err'd us, then stop sending events!
360     if (_isFail)
361       return;
362     QUrl collect_url("http://www.google-analytics.com/collect");
363     QNetworkRequest request;
364     if (_userAgent.size())
365       request.setRawHeader("User-Agent", _userAgent.toUtf8());
366     QNetworkReply * reply;
367     if (_useGET) {
368       // add cache-buster "z" to params
369       //params.addQueryItem("z", QString::number(qrand()) );
370 #if (QT_VERSION >= QT_VERSION_CHECK(5, 0, 0))
371       collect_url.setQuery(params);
372 #else
373       collect_url.setQueryItems(params.queryItems());
374 #endif
375       request.setUrl(collect_url);
376       reply = _qnam.get(request);
377     }
378     else {
379       request.setUrl(collect_url);
380       request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
381 
382 #if (QT_VERSION >= QT_VERSION_CHECK(5, 0, 0))
383       QByteArray postData = params.query(QUrl::FullyEncoded).toUtf8();
384 #else
385       QByteArray postData = params.encodedQuery();
386 #endif
387       reply = _qnam.post(request, postData);
388     }
389     _workingQueries << reply;
390     if (_workingQueries.size() == 1)
391         emit busyChanged();
392 
393     connect(reply, SIGNAL(error(QNetworkReply::NetworkError)),
394             this, SLOT(replyError(QNetworkReply::NetworkError)));
395 #if GANALYTICS_DEBUG > 1
396     qDebug() << "GAnalytics sent: " << params.toString();
397 #endif
398     reply->setParent(&_qnam);
399   }
400   mutable QNetworkAccessManager _qnam;
401   QString _trackingID;
402   QString _clientID;
403   bool _useGET; // true=GET, false=POST
404   QString _userID;
405 
406   // various parameters:
407   bool _anonymizeIP;
408   bool _cacheBust;
409   //
410   QString _userIPAddr;
411   QString _userAgent;
412   QString _appName;
413   QString _appVersion;
414 #if 0 // todo
415   // traffic sources
416   QString _documentReferrer;
417   QString _campaignName;
418   QString _campaignSource;
419   QString _campaignMedium;
420   QString _campaignKeyword;
421   QString _campaignContent;
422   QString _campaignID;
423   QString _adwordsID;
424   QString _displayAdsID;
425 #endif
426   // system info
427   QString _screenResolution;
428   QString _viewportSize;
429   QString _userLanguage;
430   // etc...
431 
432   // internal
433   bool _isFail;
434 
435   QList<QNetworkReply*> _workingQueries;
436   QEventLoop* _waitLoop;
437 };
438 
439