/
FirebaseAnalyticsJS.js
281 lines (281 loc) · 10.5 KB
/
FirebaseAnalyticsJS.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
/**
* A pure JavaScript Google Firebase Analytics implementation that uses
* the HTTPS Measurement API 2 to send events to Google Analytics.
*
* This class provides an alternative for the Firebase Analytics module
* shipped with the Firebase JS SDK. That library uses the gtag.js dependency
* and requires certain browser features. This prevents the use
* analytics on other platforms, such as Node-js and react-native.
*
* FirebaseAnalyticsJS provides a bare-bone implementation of the new
* HTTPS Measurement API 2 protocol (which is undocumented), with an API
* that follows the Firebase Analytics JS SDK.
*/
class FirebaseAnalyticsJS {
constructor(config, options) {
this.eventQueue = new Set();
this.flushEventsPromise = Promise.resolve();
// Verify the measurement- & client Ids
if (!config.measurementId)
throw new Error('No valid measurementId. Make sure to provide a valid measurementId with a G-XXXXXXXXXX format.');
if (!options.clientId)
throw new Error('No valid clientId. Make sure to provide a valid clientId with a UUID (v4) format.');
// Initialize
this.url = 'https://www.google-analytics.com/g/collect';
this.enabled = true;
this.config = config;
this.options = {
customArgs: {},
maxCacheTime: 5000,
strictNativeEmulation: false,
origin: 'firebase',
...options,
};
}
/**
* Sends 1 or more coded-events to the back-end.
* When only 1 event is provided, it is send inside the query URL.
* When more than 1 event is provided, the event-data is send in
* the body of the POST request.
*/
async send(events) {
const { config, options } = this;
let queryArgs = {
...options.customArgs,
v: 2,
tid: config.measurementId,
cid: options.clientId,
};
if (options.userLanguage)
queryArgs.ul = options.userLanguage;
if (options.appName)
queryArgs.an = options.appName;
if (options.appVersion)
queryArgs.av = options.appVersion;
if (options.docTitle)
queryArgs.dt = options.docTitle;
if (options.docLocation)
queryArgs.dl = options.docLocation;
if (options.screenRes)
queryArgs.sr = options.screenRes;
let body;
if (events.size > 1) {
body = '';
events.forEach(event => {
body += encodeQueryArgs(event) + '\n';
});
}
else if (events.size === 1) {
const event = events.values().next().value;
queryArgs = {
...event,
...queryArgs,
};
}
const args = encodeQueryArgs(queryArgs);
const url = `${this.url}?${args}`;
await fetch(url, {
method: 'POST',
cache: 'no-cache',
...(options.headers
? {
headers: options.headers,
}
: {}),
body,
});
}
async addEvent(event) {
const { userId, userProperties, screenName } = this;
// Extend the event with the currently set User-id
if (userId)
event.uid = userId;
if (screenName)
event['ep.screen_name'] = screenName;
// Add user-properties
if (userProperties) {
for (const name in userProperties) {
event[name] = userProperties[name];
}
// Reset user-properties after the first event. This is what gtag.js seems
// to do as well, although I couldn't find any docs explaining this behavior.
this.userProperties = undefined;
}
// Add the event to the queue
this.eventQueue.add(event);
// Start debounce timer
if (!this.flushEventsTimer) {
this.flushEventsTimer = setTimeout(async () => {
this.flushEventsTimer = undefined;
try {
await this.flushEventsPromise;
}
catch (err) {
// nop
}
this.flushEventsPromise = this.flushEvents();
}, this.options.maxCacheTime);
}
}
async flushEvents() {
if (!this.eventQueue.size)
return;
const events = new Set(this.eventQueue);
await this.send(events);
events.forEach(event => this.eventQueue.delete(event));
}
/**
* Clears any queued events and cancels the flush timer.
*/
clearEvents() {
this.eventQueue.clear();
if (this.flushEventsTimer) {
clearTimeout(this.flushEventsTimer);
this.flushEventsTimer = 0;
}
}
/**
* Parses an event (as passed to logEvent) and throws an error when the
* event-name or parameters are invalid.
*
* Upon success, returns the event in encoded format, ready to be send
* through the Google Measurement API v2.
*/
static parseEvent(options, eventName, eventParams) {
if (!eventName ||
!eventName.length ||
eventName.length > 40 ||
eventName[0] === '_' ||
!eventName.match(/^[A-Za-z_]+$/) ||
eventName.startsWith('firebase_') ||
eventName.startsWith('google_') ||
eventName.startsWith('ga_')) {
throw new Error('Invalid event-name specified. Should contain 1 to 40 alphanumeric characters or underscores. The name must start with an alphabetic character.');
}
const params = {
en: eventName,
_et: Date.now(),
'ep.origin': options.origin,
};
if (eventParams) {
for (const key in eventParams) {
const paramKey = SHORT_EVENT_PARAMS[key] ||
(typeof eventParams[key] === 'number' ? `epn.${key}` : `ep.${key}`);
params[paramKey] = eventParams[key];
}
}
return params;
}
/**
* Parses user-properties (as passed to setUserProperties) and throws an error when
* one of the user properties is invalid.
*
* Upon success, returns the user-properties in encoded format, ready to be send
* through the Google Measurement API v2.
*/
static parseUserProperty(options, userPropertyName, userPropertyValue) {
if (!userPropertyName.length ||
userPropertyName.length > 24 ||
userPropertyName[0] === '_' ||
!userPropertyName.match(/^[A-Za-z_]+$/) ||
userPropertyName === 'user_id' ||
userPropertyName.startsWith('firebase_') ||
userPropertyName.startsWith('google_') ||
userPropertyName.startsWith('ga_')) {
throw new Error('Invalid user-property name specified. Should contain 1 to 24 alphanumeric characters or underscores. The name must start with an alphabetic character.');
}
if (userPropertyValue !== undefined &&
userPropertyValue !== null &&
options.strictNativeEmulation &&
(typeof userPropertyValue !== 'string' || userPropertyValue.length > 36)) {
throw new Error('Invalid user-property value specified. Value should be a string of up to 36 characters long.');
}
return typeof userPropertyValue === 'number'
? `upn.${userPropertyName}`
: `up.${userPropertyName}`;
}
/**
* https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics#log-event
*/
async logEvent(eventName, eventParams) {
const event = FirebaseAnalyticsJS.parseEvent(this.options, eventName, eventParams);
if (!this.enabled)
return;
return this.addEvent(event);
}
/**
* https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics#set-analytics-collection-enabled
*/
async setAnalyticsCollectionEnabled(isEnabled) {
this.enabled = isEnabled;
}
/**
* https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics#set-current-screen
*/
async setCurrentScreen(screenName, screenClassOverride) {
if (screenName && screenName.length > 100) {
throw new Error('Invalid screen-name specified. Should contain 1 to 100 characters. Set to undefined to clear the current screen name.');
}
if (!this.enabled)
return;
this.screenName = screenName || undefined;
// On native, calling `setCurrentScreen` automatically records a screen_view event.
// Mimimic that behavior when native emulation is enabled.
// https://firebase.google.com/docs/analytics/screenviews#manually_track_screens
if (screenName && this.options.strictNativeEmulation) {
await this.logEvent('screen_view', {
screen_name: screenName,
});
}
}
/**
* https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics#set-user-id
*/
async setUserId(userId) {
if (!this.enabled)
return;
this.userId = userId || undefined;
}
/**
* https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics#set-user-properties
*/
async setUserProperties(userProperties) {
if (!this.enabled)
return;
for (const name in userProperties) {
const val = userProperties[name];
const key = FirebaseAnalyticsJS.parseUserProperty(this.options, name, val);
if (val === null || val === undefined) {
if (this.userProperties) {
delete this.userProperties[key];
}
}
else {
this.userProperties = this.userProperties || {};
this.userProperties[key] = val;
}
}
}
/**
* Clears all analytics data for this instance.
*/
async resetAnalyticsData() {
this.clearEvents();
this.screenName = undefined;
this.userId = undefined;
this.userProperties = undefined;
}
}
function encodeQueryArgs(queryArgs) {
const now = Date.now();
return Object.keys(queryArgs)
.map(key => {
return `${key}=${encodeURIComponent(key === '_et' ? Math.max(now - queryArgs[key], 0) : queryArgs[key])}`;
})
.join('&');
}
const SHORT_EVENT_PARAMS = {
currency: 'cu',
};
export default FirebaseAnalyticsJS;
//# sourceMappingURL=FirebaseAnalyticsJS.js.map