Repository: wix/Detox
Pull request #3678 (2022-11-09 21:43:07)

[Detox 20] Logger fixes and unit test coverage
## Description

Technically, this PR is 100% completed, but I'd prefer to review once more how the user sanitizing is done – all the unit tests pass, but the code still looks a bit tricky to grasp.


Patch:
diff --git a/detox/index.d.ts b/detox/index.d.ts
index 125c4dd16..24354e570 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -472,8 +472,6 @@ declare global {
                 readonly startSection: (name: string) => void;
                 /** @deprecated */
                 readonly endSection: (name: string) => void;
-                /** @private */
-                readonly invocationCall: (...args: unknown[]) => unknown;
             };
 
             /**
diff --git a/detox/package.json b/detox/package.json
index 55484068b..aa35e1ecb 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -163,8 +163,8 @@
       "runners/jest/reporters",
       "runners/jest/testEnvironment",
       "src/DetoxWorker.js",
+      "src/logger/utils/streamUtils.js",
       "src/ipc",
-      "src/logger",
       "src/realms"
     ],
     "resetMocks": true,
diff --git a/detox/src/android/interactions/native.js b/detox/src/android/interactions/native.js
index 10ee2412d..64db6741d 100644
--- a/detox/src/android/interactions/native.js
+++ b/detox/src/android/interactions/native.js
@@ -1,6 +1,7 @@
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const { expectDescription, actionDescription } = require('../../utils/invocationTraceDescriptions');
-const { trace } = require('../../utils/trace');
+const log = require('../../utils/logger').child({ cat: 'ws-client, ws' });
+const traceInvocationCall = require('../../utils/traceInvocationCall').bind(null, log);
 const { ScrollAmountStopAtEdgeAction } = require('../actions/native');
 const { NativeMatcher } = require('../core/NativeMatcher');
 const DetoxAssertionApi = require('../espressoapi/DetoxAssertion');
@@ -18,7 +19,7 @@ class Interaction {
   }
 
   async execute() {
-    return trace.invocationCall(this._traceDescription, this._call,
+    return traceInvocationCall(this._traceDescription, this._call,
       this._invocationManager.execute(this._call).then((resultObj) => resultObj ? resultObj.result : undefined));
   }
 }
diff --git a/detox/src/devices/runtime/RuntimeDevice.js b/detox/src/devices/runtime/RuntimeDevice.js
index 67e508a8e..8d7f07f55 100644
--- a/detox/src/devices/runtime/RuntimeDevice.js
+++ b/detox/src/devices/runtime/RuntimeDevice.js
@@ -1,7 +1,6 @@
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const debug = require('../../utils/debug'); // debug utils, leave here even if unused
 const log = require('../../utils/logger').child({ cat: 'device' });
-const { traceCall } = require('../../utils/trace');
 const traceMethods = require('../../utils/traceMethods');
 const wrapWithStackTraceCutter = require('../../utils/wrapWithStackTraceCutter');
 
@@ -22,6 +21,7 @@ class RuntimeDevice {
       'disableSynchronization',
       'enableSynchronization',
       'installApp',
+      'installUtilBinaries',
       'launchApp',
       'matchFace',
       'matchFinger',
@@ -258,12 +258,12 @@ class RuntimeDevice {
   async installUtilBinaries() {
     const paths = this._deviceConfig.utilBinaryPaths;
     if (paths) {
-      await traceCall('installUtilBinaries', this.deviceDriver.installUtilBinaries(paths));
+      await this.deviceDriver.installUtilBinaries(paths);
     }
   }
 
   async reloadReactNative() {
-    await traceCall('reload React Native', this.deviceDriver.reloadReactNative());
+    await this.deviceDriver.reloadReactNative();
   }
 
   async openURL(params) {
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index 67ab0ac20..54a4a59d6 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -8,7 +8,8 @@ const tempfile = require('tempfile');
 
 const { assertEnum, assertNormalized } = require('../utils/assertArgument');
 const { actionDescription, expectDescription } = require('../utils/invocationTraceDescriptions');
-const { trace } = require('../utils/trace');
+const log = require('../utils/logger').child({ cat: 'ws-client, ws' });
+const traceInvocationCall = require('../utils/traceInvocationCall').bind(null, log);
 
 const assertDirection = assertEnum(['left', 'right', 'up', 'down']);
 const assertSpeed = assertEnum(['fast', 'slow']);
@@ -747,7 +748,7 @@ function throwElementError(param) {
 }
 
 function _executeInvocation(invocationManager, invocation, traceDescription) {
-  return trace.invocationCall(traceDescription, invocation, invocationManager.execute(invocation));
+  return traceInvocationCall(traceDescription, invocation, invocationManager.execute(invocation));
 }
 
 module.exports = IosExpect;
diff --git a/detox/src/logger/DetoxLogger.js b/detox/src/logger/DetoxLogger.js
index 79525c0d4..9d67b7303 100644
--- a/detox/src/logger/DetoxLogger.js
+++ b/detox/src/logger/DetoxLogger.js
@@ -2,7 +2,6 @@ const path = require('path');
 
 const _ = require('lodash');
 
-const temporaryPath = require('../artifacts/utils/temporaryPath');
 const { DetoxInternalError, DetoxError } = require('../errors');
 const { shortFormat } = require('../utils/dateUtils');
 const isPromise = require('../utils/isPromise');
@@ -16,7 +15,7 @@ const sanitizeBunyanContext = require('./utils/sanitizeBunyanContext');
 /**
  * @typedef SharedLoggerConfig
  * @property {string} file
- * @property {Detox.DetoxLoggerConfig} userConfig
+ * @property {Detox.DetoxLoggerConfig} [userConfig]
  * @property {CategoryThreadDispatcher} [dispatcher]
  * @property {BunyanLogger} [bunyan]
  * @property {MessageStack} [messageStack]
@@ -92,7 +91,7 @@ class DetoxLogger {
    * @returns {DetoxLogger}
    */
   child(overrides) {
-    const merged = this._mergeContexts(this._context, overrides);
+    const merged = this._mergeContexts(this._context, sanitizeBunyanContext(overrides));
     return new DetoxLogger(this._sharedConfig, merged);
   }
 
@@ -116,7 +115,6 @@ class DetoxLogger {
     }
 
     _.merge(this.config, config);
-    this._sharedConfig.file = temporaryPath.for.jsonl();
     this._sharedConfig.bunyan.installDebugStream(this.config);
     this.overrideConsole();
   }
@@ -138,14 +136,23 @@ class DetoxLogger {
    */
   _mergeContexts(...contexts) {
     const context = Object.assign({}, ...contexts);
+    const categories = _(contexts).flatMap((c) => {
+      if (c && c.cat) {
+        return _.isArray(c.cat) ? c.cat : c.cat.split(',');
+      }
+
+      return [];
+    }).uniq().join(',');
 
     if (context.error || context.err) {
       context.error = DetoxError.format(context.error || context.err);
       delete context.err;
     }
 
-    if (Array.isArray(context.cat)) {
-      context.cat = context.cat.join(',');
+    if (categories) {
+      context.cat = categories;
+    } else {
+      delete context.cat;
     }
 
     if (context.__filename) {
@@ -153,15 +160,13 @@ class DetoxLogger {
     }
 
     context.ph = context.ph || 'i';
-    context.cat = context.cat || '';
-
     context.tid = this._sharedConfig.dispatcher.resolve(
       context.ph,
       context.cat,
       context.id || 0
     );
 
-    return sanitizeBunyanContext(context);
+    return context;
   }
 
   /**
@@ -182,6 +187,11 @@ class DetoxLogger {
   /** @private */
   _begin(level, ...args) {
     const { context, msg } = this._parseArgs({ ph: 'B' }, args);
+    this._beginInternal(level, context, msg);
+  }
+
+  /** @private */
+  _beginInternal(level, context, msg) {
     this._sharedConfig.messageStack.push(context.tid, msg);
     this._sharedConfig.bunyan.logger[level](context, ...msg);
   }
@@ -189,8 +199,14 @@ class DetoxLogger {
   /** @private */
   _end(level, ...args) {
     let { context, msg } = this._parseArgs({ ph: 'E' }, args);
+    this._endInternal(level, context, msg);
+  }
+
+  /** @private */
+  _endInternal(level, context, msg) {
+    const beginMsg = this._sharedConfig.messageStack.pop(context.tid);
     if (msg.length === 0) {
-      msg = this._sharedConfig.messageStack.pop(context.tid);
+      msg = beginMsg;
     }
 
     this._sharedConfig.bunyan.logger[level](context, ...msg);
@@ -221,14 +237,14 @@ class DetoxLogger {
     });
 
     let result;
-    this[level].begin(context, ...msg);
+    this._beginInternal(level, { ...context, ph: 'B' }, msg);
     try {
       result = typeof action === 'function'
         ? action()
         : action;
 
       if (!isPromise(result)) {
-        end(context);
+        end({ success: true });
       } else {
         result.then(
           () => end({ success: true }),
@@ -250,20 +266,13 @@ class DetoxLogger {
       : _.isObject(args[0])
         ? args[0]
         : undefined;
-    const msg = userContext !== undefined ? args.slice(1) : args;
 
-    if (userContext) {
-      delete userContext.pid;
-      delete userContext.tid;
-      delete userContext.ts;
-      delete userContext.time;
-      delete userContext.ph;
-    }
+    const msg = userContext !== undefined ? args.slice(1) : args;
 
     const context = this._mergeContexts(
       this._context,
       boundContext,
-      userContext,
+      sanitizeBunyanContext(userContext),
     );
 
     return { context, msg };
@@ -280,7 +289,7 @@ class DetoxLogger {
       : undefined;
 
     const cat = level === 'trace' || level === 'debug'
-      ? (value) => require('chalk').yellow((value || '').split(',', 1)[0])
+      ? (value) => require('chalk').yellow(`${value}`.split(',', 1)[0])
       : undefined;
 
     const event = level === 'trace' || level === 'debug'
diff --git a/detox/src/logger/utils/BunyanLogger.js b/detox/src/logger/utils/BunyanLogger.js
index 2fd58930b..d5ac8802c 100644
--- a/detox/src/logger/utils/BunyanLogger.js
+++ b/detox/src/logger/utils/BunyanLogger.js
@@ -27,8 +27,6 @@ class BunyanLogger {
    * @returns {this}
    */
   installDebugStream(config) {
-    const level = config.level || 'info';
-
     if (this._debugStream) {
       _.remove(this._bunyan['streams'], this._debugStream);
       // @ts-ignore
@@ -36,13 +34,17 @@ class BunyanLogger {
       this._debugStream = null;
     }
 
+    const streamOptions = { out: null, ...config.options };
+    /* istanbul ignore next */
+    if (!streamOptions.out) {
+      // This is a default if-else branch, used everywhere except for the unit tests.
+      streamOptions.out = new PassThrough().pipe(process.stderr);
+    }
+
     this._debugStream = {
       type: 'raw',
-      level,
-      stream: bds.default({
-        ...config.options,
-        out: new PassThrough().pipe(process.stderr),
-      }),
+      level: config.level,
+      stream: bds.default(streamOptions),
     };
 
     this._bunyan.addStream(this._debugStream);
@@ -54,7 +56,9 @@ class BunyanLogger {
    * @returns {this}
    */
   installFileStream(file) {
+    /* istanbul ignore next */
     if (this._fileStream) {
+      // This is an impossible condition, but we keep it here for the sake of completeness.
       throw new DetoxInternalError('Trying to install a second file stream inside already initialized Bunyan logger');
     }
 
diff --git a/detox/src/logger/utils/CategoryThreadDispatcher.js b/detox/src/logger/utils/CategoryThreadDispatcher.js
index 590be2fe0..480eebe4b 100644
--- a/detox/src/logger/utils/CategoryThreadDispatcher.js
+++ b/detox/src/logger/utils/CategoryThreadDispatcher.js
@@ -24,7 +24,7 @@ class CategoryThreadDispatcher {
 
   /** @returns {ThreadDispatcher} */
   _resolveDispatcher(cat) {
-    const mainCategory = cat ? cat.split(',', 1)[0] : 'default';
+    const mainCategory = cat ? cat.split(',', 1)[0] : 'undefined';
     if (!this._dispatchers[mainCategory]) {
       this._dispatchers[mainCategory] = new ThreadDispatcher(mainCategory);
     }
diff --git a/detox/src/logger/utils/DetoxLogFinalizer.js b/detox/src/logger/utils/DetoxLogFinalizer.js
index 4269a431d..ee103b050 100644
--- a/detox/src/logger/utils/DetoxLogFinalizer.js
+++ b/detox/src/logger/utils/DetoxLogFinalizer.js
@@ -59,15 +59,17 @@ class DetoxLogFinalizer {
 
   finalizeSync() {
     const sessionId = this._session.id;
-    const rootDir = this._config.artifacts.rootDir;
-    const logsEnabled = this._areLogsEnabled();
+    const logs = globSync(temporary.for.jsonl(`${sessionId}.*`));
+    if (logs.length === 0) {
+      return;
+    }
 
+    const logsEnabled = this._areLogsEnabled();
+    const rootDir = logsEnabled ? this._config.artifacts.rootDir : '';
     if (logsEnabled) {
       fs.mkdirpSync(rootDir);
     }
 
-    const logs = globSync(temporary.for.jsonl(`${sessionId}.*`));
-
     for (const log of logs) {
       if (logsEnabled) {
         fs.moveSync(log, path.join(rootDir, path.basename(log)));
@@ -85,6 +87,10 @@ class DetoxLogFinalizer {
 
   /** @private */
   _areLogsEnabled() {
+    if (!this._config) {
+      return false;
+    }
+
     const { rootDir, plugins } = this._config.artifacts;
     if (!rootDir || !plugins) {
       return false;
@@ -107,7 +113,7 @@ class DetoxLogFinalizer {
       const result = {};
       streamUtils().uniteSessionLogs(logs)
         .on('end', () => resolve(result))
-        .on('error', (err) => reject(err))
+        .on('error', /* istanbul ignore next */ (err) => reject(err))
         .on('data', (event) => {
           const { ph, pid, tid, cat } = event;
           if (ph === 'B' || ph === 'i') {
diff --git a/detox/src/logger/utils/sanitizeBunyanContext.js b/detox/src/logger/utils/sanitizeBunyanContext.js
index 91ed6e252..955acb2a3 100644
--- a/detox/src/logger/utils/sanitizeBunyanContext.js
+++ b/detox/src/logger/utils/sanitizeBunyanContext.js
@@ -6,6 +6,8 @@ const RESERVED_PROPERTIES = [
   'msg',
   'name',
   'pid',
+  'tid',
+  'ph',
   'time',
 ];
 
@@ -22,7 +24,7 @@ function escapeCallback(value, key) {
 }
 
 function sanitizeBunyanContext(context) {
-  return hasReservedProperties(context) ? _.mapKeys(context, escapeCallback) : context;
+  return context && hasReservedProperties(context) ? _.mapKeys(context, escapeCallback) : context;
 }
 
 module.exports = sanitizeBunyanContext;
diff --git a/detox/src/logger/utils/tracerLegacy.js b/detox/src/logger/utils/tracerLegacy.js
index 334854325..a4fcfd73d 100644
--- a/detox/src/logger/utils/tracerLegacy.js
+++ b/detox/src/logger/utils/tracerLegacy.js
@@ -1,45 +1,34 @@
 const methods = {
   startSection(logger) {
-    return (msg, args) => logger.trace.begin(args, msg);
+    return (msg, args) => {
+      if (args !== undefined) {
+        return logger.trace.begin(args, msg);
+      } else {
+        return logger.trace.begin(msg);
+      }
+    };
   },
 
   endSection(logger) {
-    return (msg, args) => logger.trace.end(args);
+    return (_msg, args) => {
+      if (args !== undefined) {
+        return logger.trace.end(args);
+      } else {
+        return logger.trace.end();
+      }
+    };
   },
 
   traceCall(logger) {
     return (name, action, args = {}) => logger.trace.complete(args, name, action);
   },
-
-  invocationCall(logger) {
-    return (sectionName, invocation, action) => {
-      return logger.trace.complete({
-        cat: 'ws-client,ws-client-invocation',
-        data: invocation,
-        stack: _getCallStackTrace(),
-      }, sectionName, action);
-    };
-  },
 };
 
-function _getCallStackTrace() {
-  return new Error().stack
-    .split('\n')
-    .slice(1) // Ignore Error message
-    .map(line => line
-      .replace(/^\s*at\s+/, '')
-      .replace(process.cwd(), '')
-    )
-    .filter(line => !line.includes('/detox/src')) // Ignore detox internal calls
-    .join('\n');
-}
-
 function installLegacyTracerInterface(logger, target) {
   target.traceCall = methods.traceCall(logger);
   target.trace = Object.freeze({
     startSection: methods.startSection(logger),
     endSection: methods.endSection(logger),
-    invocationCall: methods.invocationCall(logger),
   });
 
   return this;
diff --git a/detox/src/utils/logger.js b/detox/src/utils/logger.js
index 9482fb177..6804ea131 100644
--- a/detox/src/utils/logger.js
+++ b/detox/src/utils/logger.js
@@ -1,4 +1,4 @@
 /**
  * @type {Detox.Logger}
  */
-module.exports = require('../..').log;
+module.exports = require('../../internals').log;
diff --git a/detox/src/utils/trace.js b/detox/src/utils/trace.js
deleted file mode 100644
index c5891a1d3..000000000
--- a/detox/src/utils/trace.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const { trace, traceCall } = require('../..');
-
-module.exports = { trace, traceCall };
diff --git a/detox/src/utils/traceInvocationCall.js b/detox/src/utils/traceInvocationCall.js
new file mode 100644
index 000000000..a05c9d3e0
--- /dev/null
+++ b/detox/src/utils/traceInvocationCall.js
@@ -0,0 +1,21 @@
+function _getCallStackTrace() {
+  return new Error().stack
+    .split('\n')
+    .slice(1) // Ignore Error message
+    .map(line => line
+      .replace(/^\s*at\s+/, '')
+      .replace(process.cwd(), '')
+    )
+    .filter(line => !line.includes('/detox/src')) // Ignore detox internal calls
+    .join('\n');
+}
+
+function invocationCall(logger, sectionName, invocation, action) {
+  return logger.trace.complete({
+    cat: 'ws-client,ws-client-invocation',
+    data: invocation,
+    stack: _getCallStackTrace(),
+  }, sectionName, action);
+}
+
+module.exports = invocationCall;


Test patch:
diff --git a/.github/workflows/rapid-test.yml b/.github/workflows/rapid-test.yml
index fed833f44..04e101e16 100644
--- a/.github/workflows/rapid-test.yml
+++ b/.github/workflows/rapid-test.yml
@@ -47,6 +47,14 @@ jobs:
       - name: Typings
         run: npm test
         working-directory: detox/test
-      - name: Unit tests
+      - name: Unit tests (Linux)
+        if: matrix.os == 'ubuntu-latest'
         run: npm run unit
         working-directory: detox
+      - name: Unit tests (Windows)
+        if: matrix.os == 'windows-latest'
+        run: npm run unit -- --coverageThreshold "{}"
+        working-directory: detox
+      - name: Integration tests
+        run: npm run integration
+        working-directory: detox/test
diff --git a/detox/runners/jest/testEnvironment/listeners/DetoxCoreListener.js b/detox/runners/jest/testEnvironment/listeners/DetoxCoreListener.js
index a51a06a0f..d2be075ec 100644
--- a/detox/runners/jest/testEnvironment/listeners/DetoxCoreListener.js
+++ b/detox/runners/jest/testEnvironment/listeners/DetoxCoreListener.js
@@ -41,10 +41,22 @@ class DetoxCoreListener {
   }
 
   async test_start({ test }) {
-    if (!_.isEmpty(test.errors)) {
+    const metadata = this._getTestMetadata(test);
+    if (metadata.status === 'failed') {
       this._testsFailedBeforeStart.add(test);
     }
 
+    const logTrace = this._isTestSkipped(test)
+      ? log.trace
+      : log.trace.begin;
+
+    logTrace({
+      context: 'test',
+      status: metadata.status,
+      fullName: metadata.fullName,
+      invocations: metadata.invocations,
+    }, metadata.title);
+
     const circusRetryTimes = +this._env.global[RETRY_TIMES];
     this._circusRetryTimes = isNaN(circusRetryTimes) ? 1 : 1 + circusRetryTimes;
   }
@@ -69,7 +81,11 @@ class DetoxCoreListener {
 
   async test_fn_start({ test }) {
     await this._onBeforeActualTestStart(test);
-    log.trace.begin({ functionCode: test.fn.toString() }, 'test_fn');
+
+    if (!this._testsFailedBeforeStart.has(test)) {
+      // Jest bug workaround: beforeAll hook errors result into an unterminated test_fn_start event.
+      log.trace.begin({ functionCode: test.fn.toString() }, 'test_fn');
+    }
   }
 
   async test_fn_success() {
@@ -82,61 +98,68 @@ class DetoxCoreListener {
   }
 
   async test_done({ test }) {
-    if (this._startedTests.has(test)) {
-      const failed = test.errors.length > 0;
-      const metadata = {
-        ...this._getTestMetadata(test),
-        status: failed ? 'failed' : 'passed',
-        timedOut: failed ? hasTimedOut(test) : undefined,
-      };
+    const metadata = this._getTestMetadata(test);
 
+    if (this._startedTests.has(test)) {
       await detoxInternals.onTestDone(metadata);
       this._startedTests.delete(test);
-      log.trace.end({
-        status: metadata.status,
-        timedOut: metadata.timedOut,
-      });
     }
+
+    log.trace.end(_.pick(metadata, ['status', 'timedOut']));
   }
 
   async _onBeforeActualTestStart(test) {
     if (!this._isTestActuallyStarting(test)) {
-      return false;
+      return;
     }
 
-    const metadata = {
-      ...this._getTestMetadata(test),
-      status: 'running',
-    };
-
     this._startedTests.add(test);
-
-    log.trace.begin({
-      context: 'test',
-      status: metadata.status,
-      fullName: metadata.fullName,
-      invocations: metadata.invocations,
-    }, metadata.title);
-
-    await detoxInternals.onTestStart(metadata);
-    return true;
+    await detoxInternals.onTestStart(this._getTestMetadata(test));
   }
 
   _isTestActuallyStarting(test) {
-    return test && test.status !== 'skip' && !this._startedTests.has(test) && !this._testsFailedBeforeStart.has(test);
+    return test && !this._isTestSkipped(test) && !this._startedTests.has(test) && !this._testsFailedBeforeStart.has(test);
   }
 
-  _getTestInvocations(test) {
-    const { testSessionIndex } = detoxInternals.session;
-    return testSessionIndex * this._circusRetryTimes + test.invocations;
+  _isTestSkipped(test) {
+    return test && (test.mode === 'skip' || test.mode === 'todo');
   }
 
   _getTestMetadata(test) {
-    return {
+    const result = {
       title: test.name,
       fullName: getFullTestName(test),
+      status: this._getTestStatus(test),
       invocations: this._getTestInvocations(test),
     };
+
+    if (result.status === 'failed') {
+      result.timedOut = hasTimedOut(test);
+    }
+
+    return result;
+  }
+
+  /** @returns { 'failed' | 'passed' | 'running' | 'skip' | 'todo' } */
+  _getTestStatus(test) {
+    if (!_.isEmpty(test.errors)) {
+      return 'failed';
+    }
+
+    if (this._isTestSkipped(test)) {
+      return test.mode;
+    }
+
+    if (test.status === 'done') {
+      return 'passed';
+    } else {
+      return test.status || 'running';
+    }
+  }
+
+  _getTestInvocations(test) {
+    const { testSessionIndex } = detoxInternals.session;
+    return testSessionIndex * this._circusRetryTimes + test.invocations;
   }
 }
 
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index 8ed8dfa03..dda0b69a4 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -7,7 +7,7 @@ describe('AndroidExpect', () => {
   let device;
 
   beforeEach(() => {
-    jest.mock('../utils/trace');
+    jest.mock('../utils/logger');
     jest.mock('tempfile');
     jest.mock('fs-extra');
 
diff --git a/detox/src/client/AsyncWebSocket.test.js b/detox/src/client/AsyncWebSocket.test.js
index cb26cfa4a..0ee6aad3f 100644
--- a/detox/src/client/AsyncWebSocket.test.js
+++ b/detox/src/client/AsyncWebSocket.test.js
@@ -19,7 +19,6 @@ describe('AsyncWebSocket', () => {
 
   beforeEach(() => {
     jest.mock('../utils/logger');
-    jest.mock('../utils/trace');
     jest.mock('ws');
     WebSocket = require('ws');
     WebSocket.CONNECTING = 0;
diff --git a/detox/src/devices/allocation/DeviceAllocator.test.js b/detox/src/devices/allocation/DeviceAllocator.test.js
index 86a47f4a8..7815633e3 100644
--- a/detox/src/devices/allocation/DeviceAllocator.test.js
+++ b/detox/src/devices/allocation/DeviceAllocator.test.js
@@ -5,8 +5,6 @@ describe('Device allocator', () => {
   /** @type DeviceAllocator */
   let deviceAllocator;
   beforeEach(() => {
-    jest.mock('../../utils/trace');
-
     allocDriver = {
       allocate: jest.fn(),
       free: jest.fn(),
diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.test.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.test.js
index 410ddb68a..ddd596820 100644
--- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.test.js
+++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.test.js
@@ -34,7 +34,6 @@ describe('Allocation driver for Google emulators', () => {
   let emulatorLauncher;
   let allocationHelper;
   beforeEach(() => {
-    jest.mock('../../../../../utils/trace');
     jest.mock('../../../../common/drivers/android/exec/ADB');
     const ADB = require('../../../../common/drivers/android/exec/ADB');
     adb = new ADB();
diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js
index 460800b7d..a9896a6ea 100644
--- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js
+++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js
@@ -16,8 +16,6 @@ describe('Emulator launcher', () => {
     retry = require('../../../../../utils/retry');
     retry.mockImplementation((options, func) => func());
 
-    jest.mock('../../../../../utils/trace');
-
     const ADB = jest.genMockFromModule('../../../../common/drivers/android/exec/ADB');
     adb = new ADB();
     adb.isBootComplete.mockReturnValue(true);
diff --git a/detox/src/devices/runtime/RuntimeDevice.test.js b/detox/src/devices/runtime/RuntimeDevice.test.js
index 4c17bb640..be18a9ffc 100644
--- a/detox/src/devices/runtime/RuntimeDevice.test.js
+++ b/detox/src/devices/runtime/RuntimeDevice.test.js
@@ -18,7 +18,6 @@ describe('Device', () => {
 
   beforeEach(async () => {
     jest.mock('../../utils/logger');
-    jest.mock('../../utils/trace');
 
     jest.mock('../../utils/argparse');
     argparse = require('../../utils/argparse');
diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js
index 87d99621a..19c09839a 100644
--- a/detox/src/ios/expectTwo.test.js
+++ b/detox/src/ios/expectTwo.test.js
@@ -8,7 +8,7 @@ describe('expectTwo', () => {
   let fs;
 
   beforeEach(() => {
-    jest.mock('../utils/trace');
+    jest.mock('../utils/logger');
     jest.mock('fs-extra');
     jest.mock('tempfile');
 
diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js
index 2d38d9eba..4284d56da 100644
--- a/detox/src/ios/expectTwoApiCoverage.test.js
+++ b/detox/src/ios/expectTwoApiCoverage.test.js
@@ -3,8 +3,6 @@ describe('expectTwo API Coverage', () => {
   let e;
 
   beforeEach(() => {
-    jest.mock('../utils/trace');
-
     const IosExpect = require('./expectTwo');
 
     e = new IosExpect({
diff --git a/detox/src/logger/DetoxLogger.test.js b/detox/src/logger/DetoxLogger.test.js
new file mode 100644
index 000000000..04cb667e5
--- /dev/null
+++ b/detox/src/logger/DetoxLogger.test.js
@@ -0,0 +1,424 @@
+jest.unmock('./DetoxLogger');
+jest.useFakeTimers({
+  doNotFake: ['setTimeout', 'clearTimeout'],
+  now: new Date(2023, 0, 1),
+});
+
+const os = require('os');
+
+const fs = require('fs-extra');
+const _ = require('lodash');
+const tempfile = require('tempfile');
+
+const sleep = require('../utils/sleep');
+
+// TODO: investigate why the test fails
+if (os.platform() === 'win32') {
+  // eslint-disable-next-line no-global-assign
+  describe = describe.skip;
+}
+
+jest.retryTimes(2);
+
+describe('DetoxLogger', () => {
+  //#region --- Setup ---
+
+  /** @type {typeof import('./DetoxLogger')} */
+  let DetoxLogger;
+  /** @type {string} */
+  let jsonlLogPath;
+  /** @type {string} */
+  let plainLogPath;
+  /** @type {Promise} */
+  let safetyTimeout;
+
+  /**
+   * @param {{
+   *   level?: Detox.DetoxLogLevel;
+   *   overrideConsole?: boolean;
+   *   options?: import('bunyan-debug-stream').BunyanDebugStreamOptions;
+   * }} [opts]
+   * @returns {import('./DetoxLogger')}
+   */
+  function logger(opts = {}) {
+    const { level = 'trace', overrideConsole = false, options } = opts;
+    DetoxLogger = require('./DetoxLogger');
+
+    return new DetoxLogger({
+      file: jsonlLogPath,
+      userConfig: {
+        level,
+        overrideConsole,
+        options: {
+          ...DetoxLogger.defaultOptions({ level }),
+          colors: false,
+          // @ts-ignore
+          out: fs.createWriteStream(plainLogPath),
+          ...options,
+        },
+      },
+    });
+  }
+
+  let _env;
+
+  function setupSuite() {
+    _env = process.env;
+    process.env = { ..._env, FORCE_COLOR: '0' };
+    jsonlLogPath = tempfile('.jsonl');
+    plainLogPath = tempfile('.log');
+    safetyTimeout = null;
+  }
+
+  function teardownSuite() {
+    process.env = _env;
+    fs.removeSync(jsonlLogPath);
+    fs.removeSync(plainLogPath);
+  }
+
+  async function jsonl() {
+    safetyTimeout = safetyTimeout || sleep(10);
+    await safetyTimeout;
+    const contents = await fs.readFile(jsonlLogPath, 'utf8');
+    return contents.split('\n').filter(Boolean).map(line => JSON.parse(line));
+  }
+
+  async function txt() {
+    safetyTimeout = safetyTimeout || sleep(10);
+    await safetyTimeout;
+    const contents = await fs.readFile(plainLogPath, 'utf8');
+    return contents.trim()
+      .replace(/detox\[\d+\]/g, 'detox[PID]');
+  }
+
+  //#endregion
+
+  describe('- main functionality -', () => {
+    beforeEach(setupSuite);
+    afterEach(teardownSuite);
+
+    it('should log messages simultaneously to plain text and JSONL', async () => {
+      logger().info('Hello, world!');
+
+      await expect(txt()).resolves.toBe(`00:00:00.000 detox[PID] i  Hello, world!`);
+      await expect(jsonl()).resolves.toEqual([
+        expect.objectContaining({
+          hostname: os.hostname(),
+          level: 30,
+          msg: 'Hello, world!',
+          name: 'detox',
+          ph: 'i',
+          pid: process.pid,
+          tid: 0,
+          time: new Date().toISOString(),
+          v: 0,
+        })
+      ]);
+    });
+
+    it('should have a log level getter', () => {
+      expect(logger().level).toBe('trace'); // set in tests
+    });
+
+    it('should be able to create a child logger', async () => {
+      const parent = logger();
+      const child1 = parent.child({ cat: 'child1' });
+      const child2 = parent.child({ cat: 'child2' });
+
+      parent.info({ __filename }, 'Message from parent');
+      child1.info('Message from child1');
+      child2.info('Message from child2');
+
+      await expect(jsonl()).resolves.toEqual([
+        expect.objectContaining({ msg: 'Message from parent', __filename: 'DetoxLogger.test.js' }),
+        expect.objectContaining({ cat: 'child1', msg: 'Message from child1' }),
+        expect.objectContaining({ cat: 'child2', msg: 'Message from child2' }),
+      ]);
+    });
+
+    it('should be able to merge categories', async () => {
+      const root = logger();
+      const parent = root.child({ cat: '' });
+      const child = parent.child({ cat: ['test', 'child'] });
+      const grandchild = child.child({ cat: 'test,grandchild' });
+      const troublemaker = grandchild.child({ cat: '' });
+
+      parent.info('Message from parent');
+      child.info('Message from child');
+      grandchild.info('Message from grandchild');
+      troublemaker.info('Cannot reset the categories');
+
+      const actual = await jsonl();
+
+      expect(actual).toEqual([
+        expect.objectContaining({ msg: 'Message from parent' }),
+        expect.objectContaining({ cat: 'test,child', msg: 'Message from child' }),
+        expect.objectContaining({ cat: 'test,child,grandchild', msg: 'Message from grandchild' }),
+        expect.objectContaining({ cat: 'test,child,grandchild', msg: 'Cannot reset the categories' }),
+      ]);
+
+      expect(actual[0]).not.toHaveProperty('cat');
+    });
+
+    it('should hide messages with a lower log level in the plain output', async () => {
+      logger({ level: 'warn' }).info('Hello, world!');
+
+      await expect(txt()).resolves.toBe('');
+      await expect(jsonl()).resolves.toEqual([
+        expect.objectContaining({ msg: 'Hello, world!', level: 30 }),
+      ]);
+    });
+
+    it('should log complete duration events', async () => {
+      const l = logger();
+
+      const syncValue = l.info.complete({ extra: 'data' }, 'Info duration', 42);
+      expect(syncValue).toBe(42);
+
+      const asyncValue = l.warn.complete('Warn duration', sleep(0).then(() => 84));
+      expect(asyncValue).toBeInstanceOf(Promise);
+      expect(await asyncValue).toBe(84);
+
+      const syncResult = l.debug.complete('Debug duration', () => 126);
+      expect(syncResult).toBe(126);
+
+      const asyncResult = l.trace.complete('Trace duration', sleep(0).then(() => 168));
+      expect(asyncResult).toBeInstanceOf(Promise);
+      expect(await asyncResult).toBe(168);
+
+      try {
+        l.error.complete('Error duration (sync)', () => { throw anError('Oops (sync)!'); });
+      } catch (e) {}
+
+      await l.error.complete('Error duration (async)', sleep(0).then(() => {
+        throw anError('Oops (async)!');
+      })).catch(() => {});
+
+      const jsonlOutput = await jsonl();
+      const time = new Date().toISOString();
+      const tid = 0;
+
+      expect(jsonlOutput).toEqual([
+        expect.objectContaining({ msg: 'Info duration', ph: 'B', time, tid, level: 30, extra: 'data' }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 30, success: true }),
+        expect.objectContaining({ msg: 'Warn duration', ph: 'B', time, tid, level: 40 }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 40, success: true }),
+        expect.objectContaining({ msg: 'Debug duration', ph: 'B', time, tid, level: 20 }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 20, success: true }),
+        expect.objectContaining({ msg: 'Trace duration', ph: 'B', time, tid, level: 10 }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 10, success: true }),
+        expect.objectContaining({ msg: 'Error duration (sync)', ph: 'B', time, tid, level: 50 }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 50, success: false, error: expect.any(String) }),
+        expect.objectContaining({ msg: 'Error duration (async)', ph: 'B', time, tid, level: 50 }),
+        expect.objectContaining({ ph: 'E', time, tid, level: 50, success: false, error: expect.any(String) }),
+      ]);
+
+      await expect(txt()).resolves.toMatchSnapshot();
+    });
+
+    it('should log begin/end duration events', async () => {
+      const l = logger({
+        options: { showLevel: true }
+      }).child({ cat: 'duration' });
+      const activity1 = l.child({ id: 'thread1' });
+      const activity2 = l.child({ id: 'thread2' });
+
+      activity1.info.begin({ meta: 'data' }, 'Activity 1 start');
+      activity2.error.begin('Activity 2');
+      activity1.debug.end({ success: true }, 'Activity 1 start');
+      activity2.warn.end();
+
+      activity2.fatal.end(); // testing the fallback, when there is no begin event
+
+      await expect(txt()).resolves.toMatchSnapshot();
+    });
+
+    it.each([
+      ['trace'],
+      ['debug'],
+      ['info'],
+      ['warn'],
+      ['error'],
+      ['fatal'],
+    ])('should format messages according to the log level: %s', async (level) => {
+      // @ts-ignore
+      const l = logger({ level });
+      const err = anError('Some test err details');
+      const error = anError('Some test error details');
+
+      l.fatal('BLUE SCREEN OF DEATH');
+      l.error({ origin: 'module1', error }, 'An error occurred:');
+      l.error({ origin: 'module2', err }, 'An err (alias) occurred:');
+      l.error(anError('Error as context'), 'Another error occurred:');
+      l.warn({ origin: 'some-module/index.js', stack: 'at index.js:30:73' }, 'Warning message!');
+      l.debug({ cat: 'custom-category', event: 'MESSAGE', data: { foo: 'bar' } }, 'A message with a payload');
+      l.debug({ cat: 'custom-category', data: 'raw string data' }, 'One more message with a payload');
+      l.trace({ cat: 'custom-category', event: 'MESSAGE' }, 'Trace message');
+      l.trace({ args: ['stringArgument', { prop: 'value' }] }, 'someMethodCall');
+
+      await expect(txt()).resolves.toMatchSnapshot();
+    });
+
+    it('should allow customizing prefixers and stringifiers', async () => {
+      const l = logger({
+        options: {
+          showPrefixes: (p) => `[${p.join(', ')}]`,
+          prefixers: {
+            cat: (cat) => cat.toUpperCase(),
+          },
+          stringifiers: {
+            args: (args) => `${args.length}`,
+          },
+        },
+      });
+
+      l.trace({ cat: 'custom-category', args: [10, 20], }, 'Trace message');
+      expect(await txt()).toBe('00:00:00.000 detox[PID] [CUSTOM-CATEGORY] Trace message\n  args: 2');
+    });
+  });
+
+  describe('- overriding console -', () => {
+    const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'trace', 'assert'];
+    const LOGGER_METHODS =  ['info', 'info', 'warn', 'error', 'debug', 'info', 'error'];
+
+    let loggerInstance;
+    let spiedConsoleMethods;
+
+    describe('when overrideConsole is true', () => {
+      beforeAll(setupSuite);
+      beforeAll(spyOnLoggerAndConsoleMethods);
+      beforeAll(() => loggerInstance.setConfig({ overrideConsole: true }));
+      afterAll(teardownSuite);
+
+      it.each([
+        ['log', 'info'],
+        ['info', 'info'],
+        ['warn', 'warn'],
+        ['error', 'error'],
+        ['debug', 'debug'],
+      ])('should override console.%s with logger.%s', (consoleMethodName, loggerMethodName) => {
+        console[consoleMethodName](`${consoleMethodName}(%s)`, 'foo');
+
+        expect(spiedConsoleMethods[consoleMethodName]).not.toHaveBeenCalled();
+        expect(loggerInstance[loggerMethodName]).toHaveBeenCalledWith(expect.objectContaining({
+          cat: 'user',
+          origin: expect.any(String),
+        }), `${consoleMethodName}(foo)`);
+      });
+
+      it('should override console.trace with logger.info', () => {
+        console.trace(`trace(%s)`, 'foo');
+
+        expect(spiedConsoleMethods.trace).not.toHaveBeenCalled();
+        expect(loggerInstance.info).toHaveBeenCalledWith(expect.objectContaining({
+          cat: 'user',
+          origin: expect.any(String),
+          stack: expect.any(String),
+        }), `trace(foo)`);
+      });
+
+      it('should override console.assert with logger.error', () => {
+        console.assert(false, `Assertion failed: %s`, 'foo');
+
+        expect(spiedConsoleMethods.assert).not.toHaveBeenCalled();
+        expect(loggerInstance.error).toHaveBeenCalledWith(expect.objectContaining({
+          cat: 'user',
+          origin: expect.any(String),
+        }), 'AssertionError:', `Assertion failed: foo`);
+      });
+    });
+
+    describe('when overrideConsole is false', () => {
+      beforeAll(setupSuite);
+      beforeAll(spyOnLoggerAndConsoleMethods);
+      beforeAll(() => loggerInstance.setConfig({ overrideConsole: false }));
+      afterAll(teardownSuite);
+
+      it.each(CONSOLE_METHODS)('should not override console.%s', (consoleMethodName) => {
+        const loggerMethod = LOGGER_METHODS[CONSOLE_METHODS.indexOf(consoleMethodName)];
+        console[consoleMethodName](`${consoleMethodName}(%s)`, 'foo');
+
+        expect(loggerInstance[loggerMethod]).not.toHaveBeenCalled();
+        expect(spiedConsoleMethods[consoleMethodName]).toHaveBeenCalledWith(`${consoleMethodName}(%s)`, 'foo');
+      });
+    });
+
+    function spyOnLoggerAndConsoleMethods() {
+      spiedConsoleMethods = {};
+      loggerInstance = logger();
+
+      for (const methodName of _.uniq(LOGGER_METHODS)) {
+        jest.spyOn(loggerInstance, methodName);
+      }
+
+      for (const methodName of CONSOLE_METHODS) {
+        // @ts-ignore
+        jest.spyOn(console, methodName);
+        spiedConsoleMethods[methodName] = console[methodName];
+      }
+    }
+  });
+
+  describe('- unhappy paths -', () => {
+    beforeEach(setupSuite);
+    afterEach(teardownSuite);
+
+    it('should not allow setting config in a child logger', async () => {
+      await expect(logger().child().setConfig({ level: 'info' })).rejects.toThrowError(/Trying to set a config in a non-root logger/);
+    });
+
+    it('should escape properties conflicting with Bunyan internal ones', async () => {
+      logger().info({
+        hostname: 'hostname',
+        level: 'level',
+        msg: 'msg',
+        name: 'name',
+        pid: 'pid',
+        tid: 'tid',
+        ph: 'M',
+        time: 'time',
+        custom: 'property',
+      }, 'A very bad message');
+
+      await expect(jsonl()).resolves.toEqual( [{
+        custom: 'property',
+        hostname: os.hostname(),
+        hostname$: 'hostname',
+        level$: 'level',
+        level: 30,
+        msg: 'A very bad message',
+        msg$: 'msg',
+        name: 'detox',
+        name$: 'name',
+        pid: expect.any(Number),
+        pid$: 'pid',
+        ph: 'i',
+        ph$: 'M',
+        tid: 0,
+        tid$: 'tid',
+        time: new Date().toISOString(),
+        time$: 'time',
+        v: 0,
+      }]);
+    });
+  });
+
+  it('should cast non-standard log levels to the closest standard level', async () => {
+    const LoggerClass = logger().constructor;
+    expect(LoggerClass).toBe(DetoxLogger);
+    expect(DetoxLogger.castLevel('trace')).toBe('trace');
+    expect(DetoxLogger.castLevel('debug')).toBe('debug');
+    expect(DetoxLogger.castLevel('verbose')).toBe('debug');
+    expect(DetoxLogger.castLevel('info')).toBe('info');
+    expect(DetoxLogger.castLevel('warn')).toBe('warn');
+    expect(DetoxLogger.castLevel('error')).toBe('error');
+    expect(DetoxLogger.castLevel('fatal')).toBe('fatal');
+    expect(DetoxLogger.castLevel('unknown')).toBe('info');
+  });
+
+  function anError(msg) {
+    const err = new Error(msg);
+    err.stack = '';
+    return err;
+  }
+});
diff --git a/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap
new file mode 100644
index 000000000..c9de9880f
--- /dev/null
+++ b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap
@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: debug 1`] = `
+"00:00:00.000 detox[PID] i  BLUE SCREEN OF DEATH
+00:00:00.000 detox[PID] i  An error occurred:
+  error: Error: Some test error details
+  origin: module1
+00:00:00.000 detox[PID] i  An err (alias) occurred:
+  error: Error: Some test err details
+  origin: module2
+00:00:00.000 detox[PID] i  Another error occurred:
+  error: Error: Error as context
+00:00:00.000 detox[PID] i  Warning message!
+  stack: at index.js:30:73
+  origin: some-module/index.js
+00:00:00.000 detox[PID] i custom-category:MESSAGE A message with a payload
+  data: {
+    \\"foo\\": \\"bar\\"
+  }
+00:00:00.000 detox[PID] i custom-category One more message with a payload
+  data: raw string data"
+`;
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: error 1`] = `
+"00:00:00.000 detox[PID] i BLUE SCREEN OF DEATH
+00:00:00.000 detox[PID] i An error occurred:
+  error: Error: Some test error details
+00:00:00.000 detox[PID] i An err (alias) occurred:
+  error: Error: Some test err details
+00:00:00.000 detox[PID] i Another error occurred:
+  error: Error: Error as context"
+`;
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: fatal 1`] = `"00:00:00.000 detox[PID] i BLUE SCREEN OF DEATH"`;
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: info 1`] = `
+"00:00:00.000 detox[PID] i BLUE SCREEN OF DEATH
+00:00:00.000 detox[PID] i An error occurred:
+  error: Error: Some test error details
+00:00:00.000 detox[PID] i An err (alias) occurred:
+  error: Error: Some test err details
+00:00:00.000 detox[PID] i Another error occurred:
+  error: Error: Error as context
+00:00:00.000 detox[PID] i Warning message!"
+`;
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: trace 1`] = `
+"00:00:00.000 detox[PID] i  BLUE SCREEN OF DEATH
+00:00:00.000 detox[PID] i  An error occurred:
+  error: Error: Some test error details
+  origin: module1
+00:00:00.000 detox[PID] i  An err (alias) occurred:
+  error: Error: Some test err details
+  origin: module2
+00:00:00.000 detox[PID] i  Another error occurred:
+  error: Error: Error as context
+00:00:00.000 detox[PID] i  Warning message!
+  stack: at index.js:30:73
+  origin: some-module/index.js
+00:00:00.000 detox[PID] i custom-category:MESSAGE A message with a payload
+  data: {
+    \\"foo\\": \\"bar\\"
+  }
+00:00:00.000 detox[PID] i custom-category One more message with a payload
+  data: raw string data
+00:00:00.000 detox[PID] i custom-category:MESSAGE Trace message
+00:00:00.000 detox[PID] i  someMethodCall
+  args: (\\"stringArgument\\", {\\"prop\\":\\"value\\"})"
+`;
+
+exports[`DetoxLogger - main functionality - should format messages according to the log level: warn 1`] = `
+"00:00:00.000 detox[PID] i BLUE SCREEN OF DEATH
+00:00:00.000 detox[PID] i An error occurred:
+  error: Error: Some test error details
+00:00:00.000 detox[PID] i An err (alias) occurred:
+  error: Error: Some test err details
+00:00:00.000 detox[PID] i Another error occurred:
+  error: Error: Error as context
+00:00:00.000 detox[PID] i Warning message!"
+`;
+
+exports[`DetoxLogger - main functionality - should log begin/end duration events 1`] = `
+"00:00:00.000 detox[PID] INFO:  B duration@thread1 Activity 1 start
+00:00:00.000 detox[PID] ERROR: B duration@thread2 Activity 2
+00:00:00.000 detox[PID] DEBUG: E duration@thread1 Activity 1 start
+00:00:00.000 detox[PID] WARN:  E duration@thread2 Activity 2
+00:00:00.000 detox[PID] FATAL: E duration@thread2 <no begin message>"
+`;
+
+exports[`DetoxLogger - main functionality - should log complete duration events 1`] = `
+"00:00:00.000 detox[PID] B  Info duration
+00:00:00.000 detox[PID] E  Info duration
+00:00:00.000 detox[PID] B  Warn duration
+00:00:00.000 detox[PID] E  Warn duration
+00:00:00.000 detox[PID] B  Debug duration
+00:00:00.000 detox[PID] E  Debug duration
+00:00:00.000 detox[PID] B  Trace duration
+00:00:00.000 detox[PID] E  Trace duration
+00:00:00.000 detox[PID] B  Error duration (sync)
+00:00:00.000 detox[PID] E  Error duration (sync)
+  error: Error: Oops (sync)!
+00:00:00.000 detox[PID] B  Error duration (async)
+00:00:00.000 detox[PID] E  Error duration (async)
+  error: Error: Oops (async)!"
+`;
diff --git a/detox/src/logger/utils/DetoxLogFinalizer.test.js b/detox/src/logger/utils/DetoxLogFinalizer.test.js
new file mode 100644
index 000000000..9b2d439ab
--- /dev/null
+++ b/detox/src/logger/utils/DetoxLogFinalizer.test.js
@@ -0,0 +1,288 @@
+jest.unmock('../DetoxLogger');
+jest.useFakeTimers({
+  doNotFake: [
+    'nextTick',
+    'performance',
+    'queueMicrotask',
+    'requestAnimationFrame',
+    'cancelAnimationFrame',
+    'requestIdleCallback',
+    'cancelIdleCallback',
+    'setImmediate',
+    'clearImmediate',
+    'setInterval',
+    'clearInterval',
+    'setTimeout',
+    'clearTimeout',
+  ],
+  now: new Date(2023, 0, 1),
+});
+
+const path = require('path');
+const { PassThrough } = require('stream');
+
+const fs = require('fs-extra');
+const glob = require('glob');
+const tempfile = require('tempfile');
+
+const temporary = require('../../artifacts/utils/temporaryPath');
+const sleep = require('../../utils/sleep');
+const DetoxLogger = require('../DetoxLogger');
+
+const DetoxLogFinalizer = require('./DetoxLogFinalizer');
+
+// TODO: investigate why the test fails
+if (require('os').platform() === 'win32') {
+  // eslint-disable-next-line no-global-assign
+  describe = describe.skip;
+}
+
+describe('DetoxLogFinalizer', () => {
+  /** @type {string[]} */
+  let temporaryFiles = [];
+  /** @type {*} */
+  let session;
+  /** @type {import('./DetoxLogFinalizer')} */
+  let finalizer;
+
+  let _env;
+
+  beforeEach(() => {
+    _env = process.env;
+    process.env = { ..._env, FORCE_COLOR: '0' };
+  });
+
+  beforeEach(() => {
+    session = {
+      id: 'session-id',
+      detoxConfig: {
+        artifacts: {
+          rootDir: tempfile(''),
+          plugins: {
+            log: {
+              enabled: true,
+              keepOnlyFailedTestsArtifacts: false,
+            }
+          }
+        },
+        logger: {
+          level: 'info',
+          overrideConsole: false,
+          options: {
+            ...DetoxLogger.defaultOptions({
+              level: 'info',
+            }),
+            showDate: (date) => date.toISOString(),
+            colors: false,
+          },
+        }
+      }
+    };
+
+    temporaryFiles = [session.detoxConfig.artifacts.rootDir];
+    finalizer = new DetoxLogFinalizer({ session });
+  });
+
+  afterEach(async () => {
+    await Promise.all(temporaryFiles.map(f => fs.remove(f)));
+  });
+
+  afterEach(() => {
+    process.env = _env;
+  });
+
+  describe('finalize', () => {
+    it('should not throw when there are no logs', async () => {
+      await finalizer.finalize();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+    });
+
+    it('should convert JSONL logs to Chrome Trace format', async () => {
+      await createLogFiles();
+      await finalizer.finalize();
+
+      let plainLog = await fs.readFile(path.join(artifactsDir(), 'detox.log'), 'utf8');
+      let traceLog = JSON.parse(await fs.readFile(path.join(artifactsDir(), 'detox.trace.json'), 'utf8'));
+
+      plainLog = plainLog.replace(/detox\[\d+\]/g, 'detox[1234]');
+      traceLog = traceLog.map(t => (t.pid = 1234, t));
+
+      expect(plainLog).toMatchSnapshot();
+      expect(traceLog).toMatchSnapshot();
+    });
+
+    it('should not create logs when they are disabled', async () => {
+      session.detoxConfig.artifacts.plugins.log.enabled = false;
+      await createLogFiles();
+      await finalizer.finalize();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+      expect(fs.existsSync(temporaryFiles[1])).toBe(false);
+      expect(fs.existsSync(temporaryFiles[2])).toBe(false);
+    });
+
+    it('should not create logs for successful run when configured', async () => {
+      session.detoxConfig.artifacts.plugins.log.keepOnlyFailedTestsArtifacts = true;
+      session.testResults = [{ success: true }];
+
+      await createLogFiles();
+      await finalizer.finalize();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+      expect(fs.existsSync(temporaryFiles[1])).toBe(false);
+      expect(fs.existsSync(temporaryFiles[2])).toBe(false);
+    });
+
+    it('should create logs for failing run when configured', async () => {
+      session.detoxConfig.artifacts.plugins.log.keepOnlyFailedTestsArtifacts = true;
+      session.testResults = [{ success: false }];
+
+      await createLogFiles();
+      await finalizer.finalize();
+
+      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
+      expect(outFiles.map(f => path.basename(f))).toEqual([
+        'detox.log',
+        'detox.trace.json',
+      ]);
+    });
+  });
+
+  describe('finalizeSync', () => {
+    it('should not throw for non-initialized state', async () => {
+      await createLogFiles();
+      delete session.detoxConfig.artifacts.plugins;
+      expect(() => finalizer.finalizeSync()).not.toThrow();
+
+      await createLogFiles();
+      delete session.detoxConfig.artifacts.rootDir;
+      expect(() => finalizer.finalizeSync()).not.toThrow();
+
+      await createLogFiles();
+      delete session.detoxConfig;
+      expect(() => finalizer.finalizeSync()).not.toThrow();
+    });
+
+    it('should not throw when there are no logs', async () => {
+      finalizer.finalizeSync();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+    });
+
+    it('should copy JSONL logs to artifacts dir', async () => {
+      await createLogFiles();
+      finalizer.finalizeSync();
+
+      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
+      expect(outFiles.map(f => path.extname(f))).toEqual([
+        '.jsonl',
+        '.jsonl',
+      ]);
+    });
+
+    it('should not create logs when they are disabled', async () => {
+      session.detoxConfig.artifacts.plugins.log.enabled = false;
+      await createLogFiles();
+      finalizer.finalizeSync();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+      expect(fs.existsSync(temporaryFiles[1])).toBe(false);
+      expect(fs.existsSync(temporaryFiles[2])).toBe(false);
+    });
+
+    it('should not create logs for successful run when configured', async () => {
+      session.detoxConfig.artifacts.plugins.log.keepOnlyFailedTestsArtifacts = true;
+      session.testResults = [{ success: true }];
+
+      await createLogFiles();
+      finalizer.finalizeSync();
+      expect(fs.existsSync(artifactsDir())).toBe(false);
+      expect(fs.existsSync(temporaryFiles[1])).toBe(false);
+      expect(fs.existsSync(temporaryFiles[2])).toBe(false);
+    });
+
+    it('should create logs for failing run when configured', async () => {
+      session.detoxConfig.artifacts.plugins.log.keepOnlyFailedTestsArtifacts = true;
+      session.testResults = [{ success: false }];
+
+      await createLogFiles();
+      finalizer.finalizeSync();
+
+      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
+      expect(outFiles.map(f => path.extname(f))).toEqual([
+        '.jsonl',
+        '.jsonl',
+      ]);
+    });
+  });
+
+  describe('createEventStream', () => {
+    it('should create a stream of Chrome Trace format events', async () => {
+      await createLogFiles();
+
+      const events = [];
+      await new Promise((resolve, reject) => {
+        finalizer.createEventStream()
+          .on('end', resolve)
+          .on('error', reject)
+          .on('data', event => events.push(event));
+      });
+
+      events.forEach(t => { t.pid = 1234; });
+
+      expect(events).toMatchSnapshot();
+    });
+  });
+
+  function artifactsDir() {
+    return session.detoxConfig.artifacts.rootDir;
+  }
+
+  async function createLogFiles() {
+    const sessionId = session.id;
+    const logPath1 = temporary.for.jsonl(`${sessionId}.1`);
+    const logPath2 = temporary.for.jsonl(`${sessionId}.2`);
+
+    temporaryFiles.push(logPath1, logPath2);
+
+    const userConfig = {
+      ...session.detoxConfig.logger,
+      options: {
+        ...session.detoxConfig.logger.options,
+        out: new PassThrough(),
+      },
+    };
+
+    const log1 = new DetoxLogger({
+      file: logPath1,
+      userConfig,
+    });
+
+    const log2 = new DetoxLogger({
+      file: logPath2,
+      userConfig,
+    });
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
+    log1.info({ cat: 'main' }, 'msg1');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
+    log1.trace.begin({ id: 1, cat: 'parallel' }, 'duration1');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:01.500Z'));
+    log1.trace.begin({ id: 2, cat: 'parallel' }, 'duration2');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:02.000Z'));
+    log1.trace.end({ id: 1, cat: 'parallel' });
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:02.500Z'));
+    log1.trace.end({ id: 2, cat: 'parallel' });
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:00.500Z'));
+    log2.info.begin('long-duration');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
+    log2.debug('instant event');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:02.250Z'));
+    log2.trace.end();
+
+    await sleep(10);
+  }
+});
diff --git a/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap
new file mode 100644
index 000000000..762523d04
--- /dev/null
+++ b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap
@@ -0,0 +1,377 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DetoxLogFinalizer createEventStream should create a stream of Chrome Trace format events 1`] = `
+Array [
+  Object {
+    "args": Object {
+      "name": "primary",
+    },
+    "name": "process_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 0,
+    },
+    "name": "process_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "name": "main",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 0,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "main",
+    "name": "msg1",
+    "ph": "i",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "name": "long-duration",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration1",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "level": 20,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "name": "instant event",
+    "ph": "i",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "name": "parallel",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 1,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "id": 2,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration2",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531202000000,
+  },
+  Object {
+    "args": Object {
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531202250000,
+  },
+  Object {
+    "args": Object {
+      "id": 2,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531202500000,
+  },
+]
+`;
+
+exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format 1`] = `
+"2023-01-01T00:00:00.000Z detox[1234] i msg1
+2023-01-01T00:00:00.500Z detox[1234] B long-duration
+2023-01-01T00:00:01.000Z detox[1234] B duration1
+2023-01-01T00:00:01.000Z detox[1234] i instant event
+2023-01-01T00:00:01.500Z detox[1234] B duration2
+2023-01-01T00:00:02.000Z detox[1234] E duration1
+2023-01-01T00:00:02.250Z detox[1234] E long-duration
+2023-01-01T00:00:02.500Z detox[1234] E duration2
+"
+`;
+
+exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format 2`] = `
+Array [
+  Object {
+    "args": Object {
+      "name": "primary",
+    },
+    "name": "process_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 0,
+    },
+    "name": "process_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "name": "main",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 0,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "main",
+    "name": "msg1",
+    "ph": "i",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200000000,
+  },
+  Object {
+    "args": Object {
+      "name": "undefined",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 1,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "name": "long-duration",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "name": "parallel",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 2,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 2,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 2,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration1",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 2,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "level": 20,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "name": "instant event",
+    "ph": "i",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "name": "parallel",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 3,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 3,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 3,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "id": 2,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration2",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 3,
+    "ts": 1672531201500000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 2,
+    "ts": 1672531202000000,
+  },
+  Object {
+    "args": Object {
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "undefined",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531202250000,
+  },
+  Object {
+    "args": Object {
+      "id": 2,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 3,
+    "ts": 1672531202500000,
+  },
+]
+`;
diff --git a/detox/src/utils/childProcess/exec.test.js b/detox/src/utils/childProcess/exec.test.js
index 869f174fb..33e5d42e8 100644
--- a/detox/src/utils/childProcess/exec.test.js
+++ b/detox/src/utils/childProcess/exec.test.js
@@ -10,8 +10,6 @@ describe('Exec utils', () => {
     jest.mock('child-process-promise');
     cpp = require('child-process-promise');
 
-    jest.mock('../trace');
-
     exec = require('./exec');
   });
 
diff --git a/detox/src/utils/trace.test.js b/detox/src/utils/trace.test.js
index b4b9c5791..45d7eac3e 100644
--- a/detox/src/utils/trace.test.js
+++ b/detox/src/utils/trace.test.js
@@ -7,7 +7,7 @@ describe('Trace util', () => {
   const logger = () => require('../logger/DetoxLogger').instances[0];
 
   beforeEach(() => {
-    ({ trace, traceCall } = require('./trace'));
+    ({ trace, traceCall } = require('../..'));
   });
 
   it('startSection -> logger.trace.begin(...)', () => {
@@ -21,6 +21,11 @@ describe('Trace util', () => {
       event.args,
       event.msg
     );
+
+    trace.startSection(event.msg);
+    expect(logger().trace.begin).toHaveBeenCalledWith(
+      event.msg
+    );
   });
 
   it('endSection -> logger.trace.end(...)', () => {
@@ -31,6 +36,9 @@ describe('Trace util', () => {
 
     trace.endSection(section.msg, section.args);
     expect(logger().trace.end).toHaveBeenCalledWith(section.args);
+
+    trace.endSection(section.msg);
+    expect(logger().trace.end).toHaveBeenCalledWith();
   });
 
   it('should trace a successful function', async () => {
@@ -39,22 +47,4 @@ describe('Trace util', () => {
     expect(traceCall('42-call', functionCall)).toBe(42);
     expect(logger().trace.complete).toHaveBeenCalledWith({}, '42-call', functionCall);
   });
-
-  describe('invocation call', () => {
-    const sectionName = 'section-name';
-    const args = {
-      cat: 'ws-client,ws-client-invocation',
-      data: {
-        foo: 'bar'
-      },
-      stack: expect.any(String)
-    };
-
-    it('should trace it', async () => {
-      const promise = Promise.resolve(42);
-      const result = await trace.invocationCall(sectionName, { ...args.data }, promise);
-      expect(result).toBe(42);
-      expect(logger().trace.complete).toHaveBeenCalledWith(args, sectionName, promise);
-    });
-  });
 });
diff --git a/detox/src/utils/traceInvocationCall.test.js b/detox/src/utils/traceInvocationCall.test.js
new file mode 100644
index 000000000..d04a58f26
--- /dev/null
+++ b/detox/src/utils/traceInvocationCall.test.js
@@ -0,0 +1,27 @@
+jest.mock('../logger/DetoxLogger');
+
+describe('invocation call', () => {
+  let logger;
+  let traceInvocationCall;
+
+  beforeEach(() => {
+    logger = require('./logger');
+    traceInvocationCall = require('./traceInvocationCall').bind(null, logger);
+  });
+
+  it('should trace it', async () => {
+    const sectionName = 'section-name';
+    const args = {
+      cat: 'ws-client,ws-client-invocation',
+      data: {
+        foo: 'bar'
+      },
+      stack: expect.any(String)
+    };
+
+    const promise = Promise.resolve(42);
+    const result = await traceInvocationCall(sectionName, { ...args.data }, promise);
+    expect(result).toBe(42);
+    expect(logger.trace.complete).toHaveBeenCalledWith(args, sectionName, promise);
+  });
+});
diff --git a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
index f7f1498bd..a54b0e12e 100644
--- a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
+++ b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
@@ -113,18 +113,6 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "tid": 1,
     "ts": 8,
   },
-  {
-    "args": {
-      "level": 30,
-      "v": 0,
-    },
-    "cat": "lifecycle",
-    "name": "Stub1: should have welcome screen",
-    "ph": "i",
-    "pid": 1,
-    "tid": 1,
-    "ts": 9,
-  },
   {
     "args": {
       "context": "test",
@@ -139,6 +127,18 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "ph": "B",
     "pid": 1,
     "tid": 1,
+    "ts": 9,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: should have welcome screen",
+    "ph": "i",
+    "pid": 1,
+    "tid": 1,
     "ts": 10,
   },
   {
@@ -277,15 +277,72 @@ exports[`Timeline integration test should deterministically produce a timeline a
   },
   {
     "args": {
+      "context": "test",
+      "fullName": "Stub1 Skipped test",
+      "invocations": 1,
       "level": 10,
+      "status": "skip",
       "v": 0,
     },
     "cat": "lifecycle,jest-environment",
-    "ph": "E",
+    "name": "Skipped test",
+    "ph": "i",
     "pid": 1,
     "tid": 1,
     "ts": 19,
   },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Skipped test",
+    "ph": "i",
+    "pid": 1,
+    "tid": 1,
+    "ts": 20,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Skipped test [SKIPPED]",
+    "ph": "i",
+    "pid": 1,
+    "tid": 1,
+    "ts": 21,
+  },
+  {
+    "args": {
+      "context": "test",
+      "fullName": "Stub1 Check that todo tests are also traced",
+      "invocations": 1,
+      "level": 10,
+      "status": "todo",
+      "v": 0,
+    },
+    "cat": "lifecycle,jest-environment",
+    "name": "Check that todo tests are also traced",
+    "ph": "i",
+    "pid": 1,
+    "tid": 1,
+    "ts": 22,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Check that todo tests are also traced",
+    "ph": "i",
+    "pid": 1,
+    "tid": 1,
+    "ts": 23,
+  },
   {
     "args": {
       "level": 10,
@@ -295,7 +352,18 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "ph": "E",
     "pid": 1,
     "tid": 1,
-    "ts": 20,
+    "ts": 24,
+  },
+  {
+    "args": {
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "lifecycle,jest-environment",
+    "ph": "E",
+    "pid": 1,
+    "tid": 1,
+    "ts": 25,
   },
   {
     "args": {
@@ -307,7 +375,7 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "ph": "B",
     "pid": 1,
     "tid": 1,
-    "ts": 21,
+    "ts": 26,
   },
   {
     "args": {
@@ -318,7 +386,7 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "ph": "E",
     "pid": 1,
     "tid": 1,
-    "ts": 22,
+    "ts": 27,
   },
   {
     "args": {
@@ -330,7 +398,7 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "ph": "E",
     "pid": 1,
     "tid": 1,
-    "ts": 23,
+    "ts": 28,
   },
   {
     "args": {
@@ -346,7 +414,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 0,
     "tid": 0,
-    "ts": 24,
+    "ts": 29,
   },
   {
     "args": {
@@ -359,7 +427,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 0,
     "tid": 0,
-    "ts": 25,
+    "ts": 30,
   },
   {
     "args": {
@@ -371,7 +439,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 26,
+    "ts": 31,
   },
   {
     "args": {
@@ -383,7 +451,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 27,
+    "ts": 32,
   },
   {
     "args": {
@@ -395,7 +463,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 28,
+    "ts": 33,
   },
   {
     "args": {
@@ -407,7 +475,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 29,
+    "ts": 34,
   },
   {
     "args": {
@@ -419,7 +487,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 30,
+    "ts": 35,
   },
   {
     "args": {
@@ -431,19 +499,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 31,
-  },
-  {
-    "args": {
-      "level": 30,
-      "v": 0,
-    },
-    "cat": "lifecycle",
-    "name": "Stub1: should have welcome screen",
-    "ph": "i",
-    "pid": 2,
-    "tid": 2,
-    "ts": 32,
+    "ts": 36,
   },
   {
     "args": {
@@ -459,7 +515,19 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 33,
+    "ts": 37,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: should have welcome screen",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 38,
   },
   {
     "args": {
@@ -492,7 +560,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 34,
+    "ts": 39,
   },
   {
     "args": {
@@ -504,7 +572,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 35,
+    "ts": 40,
   },
   {
     "args": {
@@ -520,7 +588,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 36,
+    "ts": 41,
   },
   {
     "args": {
@@ -532,7 +600,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 37,
+    "ts": 42,
   },
   {
     "args": {
@@ -555,7 +623,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 38,
+    "ts": 43,
   },
   {
     "args": {
@@ -568,7 +636,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 39,
+    "ts": 44,
   },
   {
     "args": {
@@ -581,7 +649,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 40,
+    "ts": 45,
   },
   {
     "args": {
@@ -593,7 +661,75 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 41,
+    "ts": 46,
+  },
+  {
+    "args": {
+      "context": "test",
+      "fullName": "Stub1 Skipped test",
+      "invocations": 2,
+      "level": 10,
+      "status": "skip",
+      "v": 0,
+    },
+    "cat": "lifecycle,jest-environment",
+    "name": "Skipped test",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 47,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Skipped test",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 48,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Skipped test [SKIPPED]",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 49,
+  },
+  {
+    "args": {
+      "context": "test",
+      "fullName": "Stub1 Check that todo tests are also traced",
+      "invocations": 2,
+      "level": 10,
+      "status": "todo",
+      "v": 0,
+    },
+    "cat": "lifecycle,jest-environment",
+    "name": "Check that todo tests are also traced",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 50,
+  },
+  {
+    "args": {
+      "level": 30,
+      "v": 0,
+    },
+    "cat": "lifecycle",
+    "name": "Stub1: Check that todo tests are also traced",
+    "ph": "i",
+    "pid": 2,
+    "tid": 2,
+    "ts": 51,
   },
   {
     "args": {
@@ -604,7 +740,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 42,
+    "ts": 52,
   },
   {
     "args": {
@@ -615,7 +751,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 43,
+    "ts": 53,
   },
   {
     "args": {
@@ -627,7 +763,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 44,
+    "ts": 54,
   },
   {
     "args": {
@@ -638,7 +774,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 45,
+    "ts": 55,
   },
   {
     "args": {
@@ -650,7 +786,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 46,
+    "ts": 56,
   },
   {
     "args": {
@@ -661,7 +797,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 0,
     "tid": 0,
-    "ts": 47,
+    "ts": 57,
   },
 ]
 `;
diff --git a/detox/test/integration/e2e/custom-reporter.js b/detox/test/integration/e2e/custom-reporter.js
index cdd0f30ed..fb25e2b61 100644
--- a/detox/test/integration/e2e/custom-reporter.js
+++ b/detox/test/integration/e2e/custom-reporter.js
@@ -2,17 +2,18 @@ const { tracing } = require('detox/internals');
 
 class CustomReporter {
   async onRunComplete() {
-    let count = 0;
+    let counts = {};
     await new Promise((resolve, reject) => {
       tracing.createEventStream()
         .on('error', reject)
         .on('end', resolve)
         .on('data', function (e) {
-          count++;
+          counts[e.ph] = (counts[e.ph] || 0) + 1;
         });
     });
 
-    console.log(`Collected ${count} trace events during the test run`);
+    const totalCount = Object.values(counts).reduce((a, b) => a + b);
+    console.log(`Collected ${totalCount} trace events during the test run:\n`, JSON.stringify(counts));
   }
 }
 
diff --git a/detox/test/integration/e2e/stub1.test.js b/detox/test/integration/e2e/stub1.test.js
index bc9755aca..520bac123 100644
--- a/detox/test/integration/e2e/stub1.test.js
+++ b/detox/test/integration/e2e/stub1.test.js
@@ -15,4 +15,10 @@ describe('Stub1', () => {
     }
     throw new Error(`I'm only here to make things interesting!`);
   });
+
+  it.skip('Skipped test', async () => {
+    // Checking that skipped tests are also traced
+  });
+
+  it.todo('Check that todo tests are also traced');
 });
diff --git a/detox/test/integration/timeline-artifact.test.js b/detox/test/integration/timeline-artifact.test.js
index 0677f73fe..b8fc15397 100644
--- a/detox/test/integration/timeline-artifact.test.js
+++ b/detox/test/integration/timeline-artifact.test.js
@@ -32,25 +32,19 @@ describe('Timeline integration test', () => {
 
   it('should have a credible list of categories', async () => {
     const cats = _.chain(tac)
-      .flatMap(e => e.cat ? `${e.cat}`.split(',') : [])
+      .flatMap(e => e.cat ? `${e.cat}`.split(',', 1) : [])
       .uniq()
       .sort()
       .value();
 
     expect(cats).toEqual([
-      'artifact',
       'artifacts-manager',
-      'cli',
       'device',
       'ipc',
-      'ipc-server',
-      'jest-environment',
       'lifecycle',
       'user',
-      'ws',
       'ws-client',
       'ws-server',
-      'ws-session'
     ]);
   });
 
@@ -65,6 +59,17 @@ describe('Timeline integration test', () => {
     expect(unique.tid.length).toBeLessThan(unique.pid_tid.length);
     expect(unique.pid_tid.length).toBeGreaterThan(10);
   });
+
+  it('should have balanced begin/end events', () => {
+    for (const lane of _(tac).groupBy('tid').values()) {
+      const beginCount = _.filter(lane, { ph: 'B' }).length;
+      const endCount = _.filter(lane, { ph: 'E' }).length;
+      if (beginCount !== endCount) {
+        const { tid, cat } = lane[0];
+        expect(`imbalanced begin (${beginCount}) vs end (${endCount}) events in thread ${tid} (category: ${cat})`).toBeNull();
+      }
+    }
+  });
 });
 
 function isLifecycleEvent(e) {
diff --git a/detox/test/package.json b/detox/test/package.json
index 6002f6dd2..11f4444de 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -19,7 +19,7 @@
     "e2e:android:debug": "detox test -c android.emu.debug",
     "e2e-unhappy:ios": "scripts/ci_unhappy.sh ios",
     "e2e-unhappy:android": "scripts/ci_unhappy.sh android",
-    "integration": "jest --clearCache; jest --config integration/jest.config.js",
+    "integration": "jest --clearCache && jest --config integration/jest.config.js",
     "integration:console": "node integration/utils/printConsole.mjs info",
     "integration-update-snapshots": "npm run integration -- -u",
     "build:ios": "detox build -c ios.sim.release",