From 1077313baa44b658fe2b23ea302cc1693276886b Mon Sep 17 00:00:00 2001 From: Nikolay Feldman Date: Sat, 16 Apr 2022 01:09:15 -0400 Subject: [PATCH] move configuration to companion more logical to have it there given companion is responsible for interacting with GA4 api to begin with. It also simplifies some of the logic around passing around measurementID and secret --- README.md | 83 ++++++++++++++++++++++++++++++----------------- package.json | 2 +- src/app.js | 84 +++++++++++++++--------------------------------- src/companion.js | 81 +++++++++++++++++++++++----------------------- 4 files changed, 121 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 3eeda29..43aec69 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Google Analytics 4 (GA4) for Fitbit OS apps, clockfaces, and companions. This uses the new measurement protocol for GA4. Note that GA4 differs from the previous Universal Analytics as it's changed to be event based instead of sessions based. ## Installation -This module assumes you're using the [Fitbit CLI](https://dev.fitbit.com/build/guides/command-line-interface/) in your workflow, which allows you to manage packages using [npm](https://docs.npmjs.com/about-npm/). You can't include modules if using Fitbit Studio. +This module assumes you're using the [Fitbit CLI](https://dev.fitbit.com/build/guides/command-line-interface/) in your workflow, which allows you to manage packages using [npm](https://docs.npmjs.com/about-npm/). You can't include modules if you're using Fitbit Studio. ``` npm install --save fitbit-ga4 @@ -16,39 +16,40 @@ You'll also need to add permissions for `access_internet` in your `package.json` ] ``` -## Usage -Fitbit Google Analytics 4 requires an import statement in both the app and the companion. In the app, you'll also need to configure Google Analytics by entering your [Measurement ID and API Secret](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag). -#### App +## Configuration +Fitbit Google Analytics 4 requires an import in the companion in order to configure GA4. +You'll need to provide your [Measurement ID and API Secret](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag). +#### Companion ```javascript -import ga from 'fitbit-ga4/app' +import ga from 'fitbit-ga4/companion' ga.configure({ - mesaurementId: 'G-S2JKS12JK1', - apiSecret: 'coWInB_MTmOaQ3AXhR12_g' + measurementId: 'G-S2JKS12JK1', + apiSecret: 'coWInB_MTmOaQ3AXhR12_g', }) ``` -#### Companion + +## Sending events +You can send events from both the app and companion. We provide a convenience function to send events for app loading, unloading, and display turning on. This is optional. +#### App ```javascript -import 'fitbit-ga4/companion' +import ga from 'fitbit-ga4/app' + +ga.sendLoadAndDisplayOnEvents(true) +ga.send({ name: 'event_name' }) ``` -## Guide -#### Client ID -Upon installation, a persistent client ID is created to anonymously identify the device. This is required by the Measurement Protocol API. +#### Companion +```javascript +import ga from 'fitbit-ga4/companion' -#### Automatic Hits -Fitbit Google Analytics 4 will automatically send the following events: -* `load` is emitted each time the app is loaded. -* `display` is emitted each time the device display turns on or off. -* `unload` is emitted each time the app is unloaded. +ga.send({ name: 'event_name' }) +``` -#### Custom Events -In addition to the base events that GA4 supports and defines, you can also send your own custom events. All events follow the same format. You can send one event at a time, or multiple at once as an array of events. -##### Event examples +## Events with parameters +Events sent from the app and companion all support parameter similar to the GA4 spec. Additionally, you can send one event at a time, or multiple at once as an array of events. ```javascript -// single events with optional params -ga.send({ name: 'event_name }) - +// single event with params ga.send({ name: 'event_name', params: { @@ -57,14 +58,36 @@ ga.send({ } }) -// multiple events with optional params per event -ga.send([{ - name: 'event_name1' -}, { - name: 'event_name2' -}]) +// multiple events with params per event +ga.send([ + { name: 'event_name1' }, + { + name: 'event_name2', + params: { + some_param1: 'value1', + some_param2: 'value2', + } + }, +]) ``` +## Debug logs +You can enable debug logs in both the app and companion. Companion's `ga.configure` function allows for an optional `debug` field. Similarly, App exposes a `ga.setDebug(true)` function. + +## Other notes +#### Client ID +Upon installation, a persistent client ID is created to anonymously identify the device. This is required by the Measurement Protocol API. + +#### Automatic Load, Unload and Display events +We provide a convenience function to send events for app loading, unloading, and display turning on. You can enable this from the app side by invoking `ga.sendLoadAndDisplayOnEvents()`: +* `load` is emitted each time the app is loaded. +* `display_on` is emitted each time the device display turns on. +* `unload` is emitted each time the app is unloaded. + + #### Note on event timestamps -Fitbit Google Analytics 4 will best attempt to use the timestamp of the events at the time they were sent. While GA4 Measurement protocol is still in beta, it's worth noting that in the prior Universal Analytics version of the Google Analytics with hits, hits fired with a timestamp older than 4 hours may not be processed. This could have potentially lead to data loss. It's unclear whether this behavior continues on in GA4. This is problematic for fitbit analytics since the Bluetooth connection between the device and the companion is not always active, event data may be sent long after the event actually took place. To account for this possibility and the current undocumented behavior, events enqueued longer than 4 hours from the time they were sent to the time the companion wakes up will use the timestamp of when they are processed, not the actual event time. This is to be re-evaluated once GA4 Measurement protocol moves outside beta and better documented, or good outside information is discovered about this behavior. +GA4 Measurement protocol is still in beta. It's worth noting that in the prior Universal Analytics version of the Google Analytics with hits, [hits fired with a timestamp older than 4 hours may not be processed once they reach GA servers](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt). Thus, always sending events with the event timestamp could have potentially lead to data loss in UA. +It's unclear whether this behavior continues on in GA4. This is problematic for fitbit analytics since the Bluetooth connection between the device and the companion is not always active, event data may be sent long after the event actually took place. +To account for this possibility and the current undocumented behavior, events enqueued longer than 4 hours from the time they were sent to the time the companion wakes up will use the timestamp of when they are processed, not the actual event time. +This is to be re-evaluated once GA4 Measurement protocol moves outside beta and better documented, or good outside information is discovered about this behavior. diff --git a/package.json b/package.json index b0fd238..3421d33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fitbit-ga4", - "version": "0.0.1", + "version": "1.0.0", "description": "Google Analytics 4 (GA4) for Fitbit OS apps, clockfaces, and companions", "repository": { "type": "git", diff --git a/src/app.js b/src/app.js index 03c1820..194ae8f 100644 --- a/src/app.js +++ b/src/app.js @@ -1,38 +1,13 @@ import { me as appbit } from 'appbit' -import { me as device } from 'device' import { display } from 'display' import { encode } from 'cbor' import { outbox } from 'file-transfer' -import { readFileSync, writeFileSync } from 'fs' import shared from './shared' -//==================================================================================================== -// Configure -//==================================================================================================== - -let mesaurementId = null -let apiSecret = null let debug = false -let configured = false - -// Update global options -const configure = options => { - if (!options) { - return - } - mesaurementId = options.mesaurementId - apiSecret = options.apiSecret - debug = options.debug || debug - // TODO user_properties - - if (!mesaurementId || !apiSecret) { - console.log('GA4 configure: no measurement ID or API secret provided, no events will be sent.') - return - } - - configured = true - onload() +const setDebug = value => { + debug = !!value } //==================================================================================================== @@ -40,57 +15,50 @@ const configure = options => { // Can be a single event object, or array of events //==================================================================================================== const send = event => { - if (!configured) { - console.log('GA4 send: GA4 not configured, dropping event') - return - } - const data = shared.transformData(event) - data.measurementId = mesaurementId - data.apiSecret = apiSecret - data.debug = debug // Generate a unique filename - const filename = '_google_analytics_' + (Math.floor(Math.random() * 10000000000000000)) + const filename = '_google_analytics4_' + (Math.floor(Math.random() * 10000000000000000)) // Enqueue the file outbox.enqueue(filename, encode(data)).then(() => { - debug && console.log('File: ' + filename + ' transferred successfully.') + debug && console.log(`File: ${filename} transferred successfully.`) }).catch(function (error) { - debug && console.log('File: ' + filename + ' failed to transfer.') + debug && console.log(`File: ${filename} failed to transfer.`) }) } -//==================================================================================================== -// Automatic Events -//==================================================================================================== +// If invoking, ensure you're invoking at the top of your 'app' right after importing the module. +const sendLoadAndDisplayOnEvents = value => { + if (!!value === false) { + return + } -// Send a hit on load -const onload = () => { + // Send an event on load send({ name: 'load', }) -} -// Send a hit each time the display turns on -display.addEventListener('change', () => { - send({ - name: 'display', - params: { - value: display.on ? 'on' : 'off', - }, + // Send an event each time the display turns on + display.addEventListener('change', () => { + if (display.on) { + send({ + name: 'display_on', + }) + } }) -}) -// Send a hit on unload -appbit.addEventListener('unload', () => { - send({ - name: 'unload', + // Send an event on unload + appbit.addEventListener('unload', () => { + send({ + name: 'unload', + }) }) -}) +} const analytics = { - configure, + sendLoadAndDisplayOnEvents, send, + setDebug, } export default analytics diff --git a/src/companion.js b/src/companion.js index 41c8efa..6bde709 100644 --- a/src/companion.js +++ b/src/companion.js @@ -1,10 +1,29 @@ -import { encode } from 'cbor' import { inbox } from 'file-transfer' import { getDebug, setDebug, getOrGenerateClientId, getMeasurementId, setMeasurementId, getApiSecret, setApiSecret } from './local-storage' import shared from './shared' -// initial event queue fired from companion side but we dont have GA4 measurement id or secret set yet. -const initEventQueue = [] +// Update global options +const configure = options => { + if (!options) { + return + } + + const { measurementId, apiSecret, debug } = options + if (!measurementId || !apiSecret) { + console.log('GA4 configure: no measurement ID or API secret provided, no events will be sent.') + return + } + + setMeasurementId(measurementId) + setApiSecret(apiSecret) + if (typeof debug === 'boolean') { + setDebug(debug) + } + + init() +} + +const isConfigured = () => getMeasurementId() && getApiSecret() const init = () => { // Process new files as they arrive @@ -21,10 +40,9 @@ const init = () => { const send = event => { const data = shared.transformData(event) - // can only se this happening on first session right when app installed and companions happens to attempt to send some event before app does its 'load' event - if (!getMeasurementId() || !getApiSecret()) { - console.log('companion: GA4 measurement ID or secret not found, enqueuing event data.') - initEventQueue.push(data) + // drop even if not configured. Configure should be the first function invoked for companion, before any send. + if (!isConfigured()) { + console.log('companion: event sent prior to invoking configure, dropping event') } else { sendToGA(data) } @@ -34,20 +52,9 @@ const sendToGA = (data) => { if (!data) { return } - - const debug = data.debug !== undefined ? data.debug : getDebug() - - // check if incoming data has GA4 ID and secret set, persist if it's different in case of new builds - if (data.mesaurementId && data.mesaurementId != getMeasurementId()) { - console.log(`setting measure id ${data.mesaurementId}`) - setMeasurementId(data.mesaurementId) - } - if (data.apiSecret && data.apiSecret != getApiSecret()) { - console.log(`setting secret ${data.apiSecret}`) - setApiSecret(data.apiSecret) - } - if (debug != getDebug()) { - setDebug(data.debug) + if (!getMeasurementId() || !getApiSecret()) { + console.log('companion: no measurement ID or API secret') + return } const body = { @@ -55,10 +62,6 @@ const sendToGA = (data) => { events: data.events, } - debug && console.log(`Measurement ID: ${getMeasurementId()}`) - debug && console.log(`Measurement API Secret: ${getApiSecret()}`) - debug && console.log(`Client ID: ${getOrGenerateClientId()}`) - // Prefer time of event when it was enqueued. Old UA noted that events older than 4 hours may not be processed so maintain that here until documented otherwise // https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt const queueTime = Date.now() - data.timestamp @@ -67,37 +70,35 @@ const sendToGA = (data) => { } const bodyString = JSON.stringify(body) - debug && console.log('body', bodyString) - if (!getMeasurementId() || !getApiSecret()) { - console.log('companion: no measurement ID or API secret') - return - } + const debug = getDebug() + debug && console.log(`Measurement ID: ${getMeasurementId()}`) + debug && console.log(`Measurement API Secret: ${getApiSecret()}`) + debug && console.log(`Client ID: ${getOrGenerateClientId()}`) + debug && console.log('GA4 POST Payload: ', bodyString) - fetch(`https://www.google-analytics.com/mp/collect?mesaurementId=${getMeasurementId()}&apiSecret=${getApiSecret()}`, { + fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${getMeasurementId()}&api_secret=${getApiSecret()}`, { method: 'POST', body: bodyString, }) } const process_files = async () => { + if (!isConfigured()) { + return + } + let file while ((file = await inbox.pop())) { const payload = await file.cbor() - if (file.name.startsWith('_google_analytics_')) { - payload && payload.debug && console.log('File: ' + file.name + ' is being processed.') + if (file.name.startsWith('_google_analytics4_')) { + getDebug() && console.log(`File: ${file.name} is being processed.`) sendToGA(payload) } } - - // handle any items in companion queue - if (initEventQueue.length > 0) { - initEventQueue.forEach(data => send(data)) - initEventQueue = [] - } } const analytics = { - init, + configure, send, }