Skip to content

Commit aa98ecf

Browse files
committed
feat: add FCM analytics label support
1 parent cad9b14 commit aa98ecf

3 files changed

Lines changed: 175 additions & 5 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push
2525
- [Migration to FCM HTTP v1 API (June 2024)](#migration-to-fcm-http-v1-api-june-2024)
2626
- [HTTP/1.1 Legacy Option](#http11-legacy-option)
2727
- [Firebase Client Error](#firebase-client-error)
28+
- [FCM Analytics Label](#fcm-analytics-label)
2829
- [Expo Push Options](#expo-push-options)
2930
- [Bundled with Parse Server](#bundled-with-parse-server)
3031
- [Logging](#logging)
@@ -189,6 +190,22 @@ Occasionally, errors within the Firebase Cloud Messaging (FCM) client may not be
189190

190191
In both cases, detailed error logs are recorded in the Parse Server logs for debugging purposes.
191192

193+
#### FCM Analytics Label
194+
195+
To tag Firebase delivery metrics for a push notification, include `analytics_label` in the push data. The adapter validates the label and maps it to `fcmOptions.analyticsLabel` in the FCM payload.
196+
197+
```js
198+
await Parse.Push.send({
199+
channels: ['global'],
200+
data: {
201+
alert: 'Feature update',
202+
analytics_label: 'feature_update_v1'
203+
}
204+
}, { useMasterKey: true });
205+
```
206+
207+
The analytics label can contain 1 to 50 letters, numbers, or `-_.~%` characters.
208+
192209
### Expo Push Options
193210

194211
Example options:

spec/FCM.spec.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,79 @@ describe('FCM', () => {
326326
expect(payload.data.tokens).toEqual(['testToken']);
327327
});
328328

329+
it('can add an FCM analytics label from nested request data', () => {
330+
const requestData = {
331+
data: {
332+
alert: 'alert',
333+
analytics_label: 'feature_update_v1',
334+
},
335+
};
336+
337+
const payload = FCM.generateFCMPayload(
338+
requestData,
339+
'pushId',
340+
1454538822113,
341+
['testToken'],
342+
'android',
343+
);
344+
345+
expect(payload.data.fcmOptions).toEqual({
346+
analyticsLabel: 'feature_update_v1',
347+
});
348+
expect(payload.data.tokens).toEqual(['testToken']);
349+
350+
const dataFromUser = JSON.parse(payload.data.android.data.data);
351+
expect(dataFromUser).toEqual({ alert: 'alert' });
352+
});
353+
354+
it('can add an FCM analytics label from top-level request data', () => {
355+
const requestData = {
356+
analytics_label: 'campaign-1',
357+
data: {
358+
alert: 'alert',
359+
},
360+
};
361+
362+
const payload = FCM.generateFCMPayload(
363+
requestData,
364+
'pushId',
365+
1454538822113,
366+
['testToken'],
367+
'android',
368+
);
369+
370+
expect(payload.data.fcmOptions).toEqual({
371+
analyticsLabel: 'campaign-1',
372+
});
373+
});
374+
375+
it('rejects invalid FCM analytics labels', () => {
376+
const invalidLabels = [
377+
'',
378+
'has spaces',
379+
'has/slash',
380+
'a'.repeat(51),
381+
123,
382+
];
383+
384+
invalidLabels.forEach((analyticsLabel) => {
385+
const requestData = {
386+
data: {
387+
alert: 'alert',
388+
analytics_label: analyticsLabel,
389+
},
390+
};
391+
392+
expect(() => FCM.generateFCMPayload(
393+
requestData,
394+
'pushId',
395+
1454538822113,
396+
['testToken'],
397+
'android',
398+
)).toThrowError('FCM analytics_label must contain 1 to 50 letters, numbers, or -_.~% characters.');
399+
});
400+
});
401+
329402
it('can slice devices', () => {
330403
// Mock devices
331404
const devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)];
@@ -586,6 +659,27 @@ describe('FCM', () => {
586659
expect(fcmPayload.apns.headers['apns-priority']).toEqual(priority);
587660
});
588661

662+
it('can add an FCM analytics label to APNS payloads', () => {
663+
const data = {
664+
analytics_label: 'ios_campaign',
665+
alert: 'alert',
666+
};
667+
668+
const payload = FCM.generateFCMPayload(
669+
data,
670+
'pushId',
671+
1454538822113,
672+
['tokenTest'],
673+
'apple',
674+
);
675+
const fcmPayload = payload.data;
676+
677+
expect(fcmPayload.fcmOptions).toEqual({
678+
analyticsLabel: 'ios_campaign',
679+
});
680+
expect(fcmPayload.apns.payload.analytics_label).toBeUndefined();
681+
});
682+
589683
it('sets push type to alert if not defined explicitly', () => {
590684
const data = {
591685
alert: 'alert',

src/FCM.js

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import { randomString } from './PushAdapterUtils.js';
99
const LOG_PREFIX = 'parse-server-push-adapter FCM';
1010
const FCMRegistrationTokensMax = 500;
1111
const FCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // FCM allows a max of 4 weeks
12+
const fcmAnalyticsLabelRegex = /^[a-zA-Z0-9-_.~%]{1,50}$/;
1213
const apnsIntegerDataKeys = [
1314
'badge',
1415
'content-available',
1516
'mutable-content',
1617
'priority',
1718
'expiration_time',
1819
];
20+
const fcmMetadataDataKeys = [
21+
'analytics_label',
22+
];
1923

2024
export default function FCM(args, pushType) {
2125
if (typeof args !== 'object' || !args.firebaseServiceAccount) {
@@ -261,6 +265,8 @@ function _APNSToFCMPayload(requestData) {
261265
break;
262266
case 'priority':
263267
break;
268+
case 'analytics_label':
269+
break;
264270
default:
265271
apnsPayload['apns']['payload'][key] = coreData[key]; // Custom keys should be outside aps
266272
break;
@@ -282,16 +288,22 @@ function _GCMToFCMPayload(requestData, pushId, timeStamp) {
282288
}
283289

284290
if (requestData.hasOwnProperty('data')) {
291+
const data = { ...requestData.data };
285292
// FCM gives an error on send if we have apns keys that should have integer values
286293
for (const key of apnsIntegerDataKeys) {
287-
if (requestData.data.hasOwnProperty(key)) {
288-
delete requestData.data[key]
294+
if (data.hasOwnProperty(key)) {
295+
delete data[key];
296+
}
297+
}
298+
for (const key of fcmMetadataDataKeys) {
299+
if (data.hasOwnProperty(key)) {
300+
delete data[key];
289301
}
290302
}
291303
androidPayload.android.data = {
292304
push_id: pushId,
293305
time: new Date(timeStamp).toISOString(),
294-
data: JSON.stringify(requestData.data),
306+
data: JSON.stringify(data),
295307
}
296308
}
297309

@@ -312,6 +324,49 @@ function _GCMToFCMPayload(requestData, pushId, timeStamp) {
312324
return androidPayload;
313325
}
314326

327+
function getAnalyticsLabel(requestData) {
328+
let analyticsLabel;
329+
const hasTopLevelAnalyticsLabel = requestData.hasOwnProperty('analytics_label');
330+
const hasDataAnalyticsLabel = requestData.data &&
331+
typeof requestData.data === 'object' &&
332+
requestData.data.hasOwnProperty('analytics_label');
333+
334+
if (hasTopLevelAnalyticsLabel) {
335+
analyticsLabel = requestData.analytics_label;
336+
} else if (hasDataAnalyticsLabel) {
337+
analyticsLabel = requestData.data.analytics_label;
338+
}
339+
340+
if (analyticsLabel === undefined) {
341+
return undefined;
342+
}
343+
344+
if (typeof analyticsLabel !== 'string' || !fcmAnalyticsLabelRegex.test(analyticsLabel)) {
345+
throw new Parse.Error(
346+
Parse.Error.PUSH_MISCONFIGURED,
347+
'FCM analytics_label must contain 1 to 50 letters, numbers, or -_.~% characters.',
348+
);
349+
}
350+
351+
return analyticsLabel;
352+
}
353+
354+
function applyAnalyticsLabel(fcmPayload, requestData) {
355+
const analyticsLabel = getAnalyticsLabel(requestData);
356+
357+
if (!analyticsLabel) {
358+
return fcmPayload;
359+
}
360+
361+
return {
362+
...fcmPayload,
363+
fcmOptions: {
364+
...fcmPayload.fcmOptions,
365+
analyticsLabel,
366+
},
367+
};
368+
}
369+
315370
/**
316371
* Converts payloads used by APNS or GCM into a FCMv1-compatible payload.
317372
* Purpose is to remain backwards-compatible will payloads used in the APNS.js and GCM.js modules.
@@ -327,16 +382,20 @@ function payloadConverter(requestData, pushType, pushId, timeStamp) {
327382
return requestData.rawPayload;
328383
}
329384

385+
let fcmPayload;
386+
330387
if (pushType === 'apple') {
331-
return _APNSToFCMPayload(requestData);
388+
fcmPayload = _APNSToFCMPayload(requestData);
332389
} else if (pushType === 'android') {
333-
return _GCMToFCMPayload(requestData, pushId, timeStamp);
390+
fcmPayload = _GCMToFCMPayload(requestData, pushId, timeStamp);
334391
} else {
335392
throw new Parse.Error(
336393
Parse.Error.PUSH_MISCONFIGURED,
337394
'Unsupported push type, apple or android only.',
338395
);
339396
}
397+
398+
return applyAnalyticsLabel(fcmPayload, requestData);
340399
}
341400

342401
/**

0 commit comments

Comments
 (0)