Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* Added support for objects as campaign data in `recordDirectAttribution` and as values in `recordIndirectAttribution`, in `Countly` and in `CountlyConfig`.
* Added the names "DEVELOPMENT", "PRODUCTION" and "ADHOC" as accepted values of `CountlyConfig.setPushTokenType`.
* Added `CountlyConfig.setUpdateSessionTimerDelay(delay)` to set how often, in seconds, the SDK sends automatic session updates and the queued events. The default is 60 seconds, and a value that is not a whole number from 1 to 2147483647 is ignored with an error log.
* Added `CountlyConfig.setLogListener(listener)` for receiving every log message of the SDK and its level, also while logging is disabled:
* The listener gets the messages of the React Native layer and of the native SDK, each with "error", "warning", "info", "debug" or "verbose".
* It is called inside the SDK's own calls, so it should return quickly and must not call the SDK.

* Mitigated remote config update issues:
* Updates without remote config consent, in temporary device ID mode or with SDK networking disabled now complete with an error in iOS, instead of never completing.
Expand Down
20 changes: 20 additions & 0 deletions Countly.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ type ContentStatus = "COMPLETED" | "CLOSED";

type ContentCallback = (status: ContentStatus, data: Record<string, any>) => void;

/**
* Level of a log line, as the log listener receives it.
*/
type CountlyLogLevel = "error" | "warning" | "info" | "debug" | "verbose";

/**
* Called with every log line of the SDK and its level, also when logging is disabled.
*/
type CountlyLogListener = (message: string, level: CountlyLogLevel) => void;

/**
* Called when the user taps a push notification.
* actionIndex is 0 for the notification itself and 1 and up for its buttons.
Expand Down Expand Up @@ -1252,6 +1262,16 @@ declare module "countly-sdk-react-native-bridge/CountlyConfig" {
*/
setLoggingEnabled(loggingEnabled: boolean): CountlyConfig;

/**
* Method to set a listener that receives every log line of the SDK and its level,
* also when logging is disabled. It receives the lines of the native SDK and of the
* React Native layer. The listener is called inside the SDK's own calls, so it should
* return quickly and must not call the SDK.
*
* @param {CountlyLogListener} logListener called with the message and its level
*/
setLogListener(logListener: CountlyLogListener): CountlyConfig;

/**
* Method to enable crash reporting to report unhandled crashes to Countly
*/
Expand Down
11 changes: 11 additions & 0 deletions Countly.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
const ratingWidgetCallbackName = "ratingWidgetCallback";
const pushNotificationCallbackName = "pushNotificationCallback";
const pushNotificationOpenedCallbackName = "pushNotificationOpenedCallback";
const logListenerCallbackName = "logListenerCallback";

function removeSubscription(stateKey) {
const subscription = _state[stateKey];
Expand All @@ -72,8 +73,10 @@

function resetBridgeStateForTesting() {
removeSubscription("globalContentCallbackSubscription");
removeSubscription("logListenerSubscription");
removeSubscription("widgetShownCallback");
removeSubscription("widgetClosedCallback");
L.setListener(null);

if (_ratingWidgetListener && typeof _ratingWidgetListener.remove === "function") {
_ratingWidgetListener.remove();
Expand Down Expand Up @@ -153,7 +156,7 @@
const jsStackTrace = Utils.getStackTrace(error);
let errorTitle;
let stackArr;
if (jsStackTrace == null || jsStackTrace.length === 0) {

Check warning on line 159 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 159 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
errorTitle = errorName;
stackArr = typeof error.stack === "string" ? error.stack : "";
} else {
Expand All @@ -169,7 +172,7 @@
}

const matches = row.match(regExp);
return matches && matches.length == 8 ? `${matches[1]}${matches[2]}${matches[4]}(${matches[6]}:${matches[7]})` : row;

Check warning on line 175 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 175 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
});
stackArr = stackArr.join("\n");
}
Expand Down Expand Up @@ -227,6 +230,7 @@
L.e("init, config should not be null or undefined");
return;
}
L.setListener(countlyConfig._logListener);
if (countlyConfig.deviceID !== undefined && countlyConfig.deviceID !== null && (typeof countlyConfig.deviceID !== "string" || countlyConfig.deviceID === "")) {
L.e("init, Device ID during init must be a non-empty string. Value will be ignored and the SDK will generate one.");
}
Expand All @@ -251,6 +255,12 @@
}
});
}
removeSubscription("logListenerSubscription");
if (countlyConfig._logListener) {
_state.logListenerSubscription = eventEmitter.addListener(logListenerCallbackName, (data) => {
L.notifyListener(data.message, data.level);
});
}
const args = [];
const argsMap = Utils.configToJson(countlyConfig);
const argsString = JSON.stringify(argsMap);
Expand All @@ -260,6 +270,7 @@
} catch (error) {
L.e(`init, native init failed: ${error && error.message ? error.message : String(error)}`);
removeSubscription("globalContentCallbackSubscription");
removeSubscription("logListenerSubscription");
return;
}
if (countlyConfig._crashReporting) {
Expand Down Expand Up @@ -777,7 +788,7 @@
return message;
}
keyName = keyName.toString();
if (keyName && (keyValue || keyValue == "")) {

Check warning on line 791 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 791 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userData_setProperty([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -938,7 +949,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 952 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 952 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userData_setOnce([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -967,7 +978,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 981 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 981 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userData_pushUniqueValue([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -996,7 +1007,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 1010 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 1010 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userData_pushValue([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -1025,7 +1036,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 1039 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 1039 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userData_pullValue([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -1118,7 +1129,7 @@
return message;
}
keyName = keyName.toString();
if (keyName && (keyValue || keyValue == "")) {

Check warning on line 1132 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 1132 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userDataBulk_setProperty([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -1285,7 +1296,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 1299 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 1299 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userDataBulk_setOnce([keyName, keyValue]);
}
};
Expand Down Expand Up @@ -1315,7 +1326,7 @@
return message;
}
keyValue = keyValue.toString();
if (keyValue || keyValue == "") {

Check warning on line 1329 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (24)

Expected '===' and instead saw '=='

Check warning on line 1329 in Countly.js

View workflow job for this annotation

GitHub Actions / checks (22)

Expected '===' and instead saw '=='
await CountlyNativeModule.userDataBulk_pushUniqueValue([keyName, keyValue]);
}
};
Expand Down
26 changes: 26 additions & 0 deletions CountlyConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class CountlyConfig {

#globalViewSegmentation;

#logListener = null;

constructor(serverURL, appKey) {
this.serverURL = serverURL;
this.appKey = appKey;
Expand Down Expand Up @@ -145,6 +147,13 @@ class CountlyConfig {
return this.#globalViewSegmentation;
}

/**
* Getter to get the log listener set with setLogListener
*/
get _logListener() {
return this.#logListener;
}

/**
* Method to set the server url
*
Expand Down Expand Up @@ -282,6 +291,23 @@ class CountlyConfig {
return this;
}

/**
* Method to set a listener that receives every log line of the SDK and its level
* ("error", "warning", "info", "debug" or "verbose"), also when logging is disabled.
* It receives the lines of the native SDK and of the React Native layer. The listener
* is called inside the SDK's own calls, so it should return quickly and must not call the SDK.
*
* @param {function(string, string)} logListener called with the message and its level
*/
setLogListener(logListener) {
if (typeof logListener !== "function") {
L.w("setLogListener, the log listener must be a function, ignoring it");
return this;
}
this.#logListener = logListener;
return this;
}

/**
* Method to enable crash reporting to report unhandled crashes to Countly
*/
Expand Down
34 changes: 33 additions & 1 deletion Logger.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const countlyNamespace = "[CountlyReactNative] ";
let canLog = false;
let logListener = null;

/**
* Initialize logger
Expand All @@ -10,6 +11,32 @@ function initialize(debugMode) {
i("[Logger] initializing the module");
}

/**
* Sets the listener that receives every log line and its level, also when logging is disabled.
* @param {Function} listener - called with the message and its level, anything else removes the listener
*/
function setListener(listener) {
logListener = typeof listener === "function" ? listener : null;
}

/**
* Passes a log line to the log listener, if one is set. A listener that throws does not break the SDK.
* @param {String} message - the log line
* @param {String} level - "error", "warning", "info", "debug" or "verbose"
*/
function notifyListener(message, level) {
if (!logListener) {
return;
}
try {
logListener(message, level);
} catch (error) {
if (canLog) {
console.error(`${countlyNamespace}[Logger] notifyListener, the log listener threw an error: ${error}`);
}
}
}

/**
* Error - this is for things that need immediate attention because SDK won’t work.
* @param {String} message
Expand All @@ -18,6 +45,7 @@ function e(message) {
if (canLog) {
console.error(countlyNamespace + message);
}
notifyListener(countlyNamespace + message, "error");
}

/**
Expand All @@ -29,6 +57,7 @@ function w(message) {
if (canLog) {
console.warn(countlyNamespace + message);
}
notifyListener(countlyNamespace + message, "warning");
}

/**
Expand All @@ -39,6 +68,7 @@ function i(message) {
if (canLog) {
console.info(countlyNamespace + message);
}
notifyListener(countlyNamespace + message, "info");
}

/**
Expand All @@ -51,6 +81,7 @@ function d(message) {
if (canLog) {
console.debug(countlyNamespace + message);
}
notifyListener(countlyNamespace + message, "debug");
}

/**
Expand All @@ -62,6 +93,7 @@ function v(message) {
if (canLog) {
console.log(`[VERBOSE]${countlyNamespace}${message}`);
}
notifyListener(countlyNamespace + message, "verbose");
}

export { initialize, e, w, i, d, v };
export { initialize, setListener, notifyListener, e, w, i, d, v };
4 changes: 4 additions & 0 deletions Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ function configToJson(config) {
L.i(`init configuration, Enabled logging in ${__DEV__ ? "development" : "production"} mode`);
}
}
if (config._logListener) {
json.logListener = true;
L.i("init configuration, Set a log listener");
}
if (config._crashReporting) {
json.crashReporting = true;
L.i("init configuration, Enabled crash reporting");
Expand Down
24 changes: 24 additions & 0 deletions __tests__/countly.bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1174,3 +1174,27 @@ test('test helpers do nothing in release builds', async () => {
expect(nativeModule.halt).not.toHaveBeenCalled();
expect(CountlyState.isInitialized).toBe(true);
});

test('initWithConfig passes the JavaScript and the native log lines to the log listener', async () => {
const { Countly, CountlyConfig, nativeModule, listeners, emit } = loadCountlyBridge();
const lines = [];
const config = new CountlyConfig('https://server.count.ly', 'app-key').setLogListener((message, level) => lines.push([message, level]));

await Countly.initWithConfig(config);

expect(getNativeInitPayload(nativeModule).logListener).toBe(true);
expect(listeners.map((listener) => listener.eventName)).toContain('logListenerCallback');
expect(lines).toContainEqual(['[CountlyReactNative] initWithConfig, Initializing Countly', 'debug']);

emit('logListenerCallback', { message: '[ModuleEvents] recordEventInternal, key:[[CLY]_view]', level: 'info' });
expect(lines[lines.length - 1]).toEqual(['[ModuleEvents] recordEventInternal, key:[[CLY]_view]', 'info']);
});

test('setLogListener ignores a value that is not a function', async () => {
const { Countly, CountlyConfig, nativeModule, listeners } = loadCountlyBridge();

await Countly.initWithConfig(new CountlyConfig('https://server.count.ly', 'app-key').setLogListener('not a function'));

expect(getNativeInitPayload(nativeModule)).not.toHaveProperty('logListener');
expect(listeners.map((listener) => listener.eventName)).not.toContain('logListenerCallback');
});
61 changes: 60 additions & 1 deletion __tests__/logger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,63 @@ test('initialize(false) disables logger output', () => {
expect(infoSpy).not.toHaveBeenCalled();
expect(debugSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
});

test('setListener passes every level to the listener while logging is disabled', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const Logger = loadLogger();
const lines = [];

Logger.initialize(false);
Logger.setListener((message, level) => lines.push([message, level]));
Logger.e('boom');
Logger.w('careful');
Logger.i('called');
Logger.d('details');
Logger.v('verbose');
Logger.notifyListener('[Countly] [Info] native line', 'info');

expect(lines).toEqual([
['[CountlyReactNative] boom', 'error'],
['[CountlyReactNative] careful', 'warning'],
['[CountlyReactNative] called', 'info'],
['[CountlyReactNative] details', 'debug'],
['[CountlyReactNative] verbose', 'verbose'],
['[Countly] [Info] native line', 'info'],
]);
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});

test('a throwing listener does not break logging and is reported only while logging is enabled', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'info').mockImplementation(() => {});
const Logger = loadLogger();
const listener = jest.fn(() => {
throw new Error('listener failure');
});

Logger.initialize(false);
Logger.setListener(listener);
expect(() => Logger.w('first')).not.toThrow();
expect(errorSpy).not.toHaveBeenCalled();

Logger.initialize(true);
expect(() => Logger.w('second')).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith('[CountlyReactNative] second');
expect(errorSpy).toHaveBeenCalledWith('[CountlyReactNative] [Logger] notifyListener, the log listener threw an error: Error: listener failure');
expect(listener).toHaveBeenCalledTimes(3);
});

test('setListener with anything but a function removes the listener', () => {
const Logger = loadLogger();
const listener = jest.fn();

Logger.setListener(listener);
Logger.setListener('not a function');
Logger.e('boom');

expect(listener).not.toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import java.util.Set;
import java.util.HashSet;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.Iterator;
Expand Down Expand Up @@ -126,6 +127,7 @@ public class CountlyReactNativeImpl extends ReactContextBaseJavaModule implement
private static String ratingWidgetCallbackName = "ratingWidgetCallback";
private static String pushNotificationCallbackName = "pushNotificationCallback";
private static String pushNotificationOpenedCallbackName = "pushNotificationOpenedCallback";
private static String logListenerCallbackName = "logListenerCallback";
private static final String POST_NOTIFICATIONS_PERMISSION = "android.permission.POST_NOTIFICATIONS";
private static final int POST_NOTIFICATIONS_API_LEVEL = 33;
private static final int POST_NOTIFICATIONS_REQUEST_CODE = 1853;
Expand Down Expand Up @@ -634,6 +636,20 @@ private void populateConfig(JSONObject _config) {
config.setLoggingEnabled(isEnabled);
}
});
applyConfigStep("logListener", () -> {
if (_config.has("logListener")) {
config.setLogListener((logMessage, logLevel) -> {
try {
WritableMap data = Arguments.createMap();
data.putString("message", logMessage);
data.putString("level", logLevel.name().toLowerCase(Locale.ROOT));
_reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(logListenerCallbackName, data);
} catch (Exception ignored) {
// no running React instance to pass the line to, and a throwing listener would keep the line from log gathering
}
});
}
});
applyConfigStep("shouldRequireConsent", () -> {
if (_config.has("shouldRequireConsent")) {
config.setRequiresConsent(_config.getBoolean("shouldRequireConsent"));
Expand Down
Loading
Loading