Skip to content

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!

js
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.

js
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:

shell
# [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.