Client-Side Local Logging Implementation | React Native
Logging can help developers and maintainers locate problems. Client-side logs generally need to be sent to the server in order for them to be viewed by the appropriate personnel. But given the cost, some in-house client-side applications can compromise by using client-side local logging as well.
Dependencies
- react-native-fs: Native filesystem access for React Native.
- Moment.js: A JavaScript date library for parsing, validating, manipulating, and formatting dates.
Implementation
After installing the dependencies above, everything we need is in a JS file cacheLogger.js
. Let's cut the crap and show the codes!
import RNFS from 'react-native-fs';
import moment from 'moment';
import { Platform } from "react-native";
// Record to file and output on console.
const cacheLogger = {
error: (...args) => append(true, 'error', ...args),
warn: (...args) => append(true, 'warn', ...args),
info: (...args) => append(true, 'info', ...args),
log: (...args) => append(true, 'log', ...args),
debug: (...args) => append(true, 'debug', ...args),
}
export default cacheLogger;
// Record to file only, not output on console.
export const cache = {
error: (...args) => append(false, 'error', ...args),
warn: (...args) => append(false, 'warn', ...args),
info: (...args) => append(false, 'info', ...args),
log: (...args) => append(false, 'log', ...args),
debug: (...args) => append(false, 'debug', ...args),
}
export const dirPathForLogFiles = Platform.select({
android: RNFS.ExternalDirectoryPath + '/logs', // For iOS, please implement it yourself.
});
// A function to append a new record to the end of the log file.
function append(print, type, ...args) {
if (print) console[type]('|cLog|', ...args);
const m = moment().utcOffset(+8);
RNFS.appendFile(
`${dirPathForLogFiles}/${m.format('YYYYMMDD')}.log`,
'# ' + `[${m.format()}][${type.toUpperCase()}]\n` +
'- ' + args.map(arg => stringify(arg)).join('\n- ') +
'\n\n',
'utf8'
).catch((err) => console.warn(err.message));
}
// Optimized JSON.stringify()
// It handles 3 special cases and circular references to objects, but still can't handle `symbol-keyed`.
export function stringify(obj) {
const seen = new WeakMap();
return JSON.stringify(obj, (key, val) => {
if (typeof val === 'function') return `<function>`;
if (typeof val === 'undefined') return `<undefined>`;
if (typeof val === 'symbol') return `<Symbol>${val.description}`;
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return `<sameObj>${seen.get(val)}`;
seen.set(val, key);
}
return val;
})
}
export async function deleteOldLogFiles() {
const expireDays = 14; // Set log files valid for 14 days.
const expireDate = moment().subtract(expireDays, 'days');
try {
const files = (await RNFS.readDir(dirPathForLogFiles))
.sort((a, b) => moment(a.mtime) - moment(b.mtime)); // Ascending chronological order (older first).
let remainFileCount = files.length;
for (let file of files) {
if (remainFileCount <= expireDays) break; // Deletion is considered when the number of files exceeds expireDays.
const stat = await RNFS.stat(file.path);
const fileDate = moment(stat.mtime);
if (fileDate.isBefore(expireDate)) {
await RNFS.unlink(file.path);
remainFileCount -= 1;
cacheLogger.log(`[cacheLogger] Log file deleted:`, file.path);
}
}
} catch (err) {
cacheLogger.warn(err);
}
}
// Create a log folder (Don't worry. It's an idempotent operation).
RNFS.mkdir(dirPathForLogFiles)
.catch((err) => console.warn(err.message));
import RNFS from 'react-native-fs';
import moment from 'moment';
import { Platform } from "react-native";
// Record to file and output on console.
const cacheLogger = {
error: (...args) => append(true, 'error', ...args),
warn: (...args) => append(true, 'warn', ...args),
info: (...args) => append(true, 'info', ...args),
log: (...args) => append(true, 'log', ...args),
debug: (...args) => append(true, 'debug', ...args),
}
export default cacheLogger;
// Record to file only, not output on console.
export const cache = {
error: (...args) => append(false, 'error', ...args),
warn: (...args) => append(false, 'warn', ...args),
info: (...args) => append(false, 'info', ...args),
log: (...args) => append(false, 'log', ...args),
debug: (...args) => append(false, 'debug', ...args),
}
export const dirPathForLogFiles = Platform.select({
android: RNFS.ExternalDirectoryPath + '/logs', // For iOS, please implement it yourself.
});
// A function to append a new record to the end of the log file.
function append(print, type, ...args) {
if (print) console[type]('|cLog|', ...args);
const m = moment().utcOffset(+8);
RNFS.appendFile(
`${dirPathForLogFiles}/${m.format('YYYYMMDD')}.log`,
'# ' + `[${m.format()}][${type.toUpperCase()}]\n` +
'- ' + args.map(arg => stringify(arg)).join('\n- ') +
'\n\n',
'utf8'
).catch((err) => console.warn(err.message));
}
// Optimized JSON.stringify()
// It handles 3 special cases and circular references to objects, but still can't handle `symbol-keyed`.
export function stringify(obj) {
const seen = new WeakMap();
return JSON.stringify(obj, (key, val) => {
if (typeof val === 'function') return `<function>`;
if (typeof val === 'undefined') return `<undefined>`;
if (typeof val === 'symbol') return `<Symbol>${val.description}`;
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return `<sameObj>${seen.get(val)}`;
seen.set(val, key);
}
return val;
})
}
export async function deleteOldLogFiles() {
const expireDays = 14; // Set log files valid for 14 days.
const expireDate = moment().subtract(expireDays, 'days');
try {
const files = (await RNFS.readDir(dirPathForLogFiles))
.sort((a, b) => moment(a.mtime) - moment(b.mtime)); // Ascending chronological order (older first).
let remainFileCount = files.length;
for (let file of files) {
if (remainFileCount <= expireDays) break; // Deletion is considered when the number of files exceeds expireDays.
const stat = await RNFS.stat(file.path);
const fileDate = moment(stat.mtime);
if (fileDate.isBefore(expireDate)) {
await RNFS.unlink(file.path);
remainFileCount -= 1;
cacheLogger.log(`[cacheLogger] Log file deleted:`, file.path);
}
}
} catch (err) {
cacheLogger.warn(err);
}
}
// Create a log folder (Don't worry. It's an idempotent operation).
RNFS.mkdir(dirPathForLogFiles)
.catch((err) => console.warn(err.message));
Usage
Just as you use console.log()
, use cacheLogger.log()
to write to a file at the same time as console. If you only intend to write to a file, use the cache.log()
method instead.
import cacheLogger, { cache, dirPathForLogFiles } from 'path/to/cacheLogger.js';
cache.debug('[AppInfo] App Launched');
cacheLogger.info('[AppInfo] __DEV__:', __DEV__);
cacheLogger.info('[AppInfo] dirPathForLogFiles:', dirPathForLogFiles);
// Print and record device storage information.
RNFS.getFSInfo().then(fsInfo => {
const fsInfoObj = Object.fromEntries(Object.entries(fsInfo).map(([key, val]) => [key, formatBytes(val)]));
cacheLogger.info('[AppInfo] RNFS.getFSInfo():', fsInfoObj);
})
// Converts storage capacity units in human-readable format.
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
import cacheLogger, { cache, dirPathForLogFiles } from 'path/to/cacheLogger.js';
cache.debug('[AppInfo] App Launched');
cacheLogger.info('[AppInfo] __DEV__:', __DEV__);
cacheLogger.info('[AppInfo] dirPathForLogFiles:', dirPathForLogFiles);
// Print and record device storage information.
RNFS.getFSInfo().then(fsInfo => {
const fsInfoObj = Object.fromEntries(Object.entries(fsInfo).map(([key, val]) => [key, formatBytes(val)]));
cacheLogger.info('[AppInfo] RNFS.getFSInfo():', fsInfoObj);
})
// Converts storage capacity units in human-readable format.
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
Launch the Android App, and we'll see that a new file /Android/data/com.sample.app.name/files/logs/YYYYMMDD.log
has been created in the device:
# [2023-09-25T10:00:45+08:00][DEBUG]
- "[AppInfo] App Launched"
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] __DEV__:"
- false
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] dirPathForLogFiles:"
- "/storage/emulated/0/Android/data/com.sample.app.name/files/logs"
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] RNFS.getFSInfo():"
- {"totalSpaceEx":"107.18 GB","freeSpace":"98.96 GB","freeSpaceEx":"98.96 GB","totalSpace":"107.18 GB"}
# [2023-09-25T10:00:45+08:00][DEBUG]
- "[AppInfo] App Launched"
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] __DEV__:"
- false
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] dirPathForLogFiles:"
- "/storage/emulated/0/Android/data/com.sample.app.name/files/logs"
# [2023-09-25T10:00:45+08:00][INFO]
- "[AppInfo] RNFS.getFSInfo():"
- {"totalSpaceEx":"107.18 GB","freeSpace":"98.96 GB","freeSpaceEx":"98.96 GB","totalSpace":"107.18 GB"}
Lastly, react-native-fs
is very fast and did not cause any IO blocking during my usage.