Skip to content

Commit

Permalink
move configuration to companion
Browse files Browse the repository at this point in the history
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
  • Loading branch information
codeniko committed Apr 16, 2022
1 parent d86983a commit 1077313
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 129 deletions.
83 changes: 53 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: {
Expand All @@ -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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
84 changes: 26 additions & 58 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,96 +1,64 @@
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
}

//====================================================================================================
// Send
// 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
81 changes: 41 additions & 40 deletions src/companion.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Expand All @@ -34,31 +52,16 @@ 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 = {
client_id: getOrGenerateClientId(),
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
Expand All @@ -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,
}

Expand Down

0 comments on commit 1077313

Please # to comment.