Commit e7d8ab529be for nodejs

commit e7d8ab529bedcf04b91d2a65f8084be6422a0a9e
Author: Chengzhong Wu <legendecas@gmail.com>
Date:   Thu Sep 24 13:30:20 2026 -0400

    test: enable trace_events tests with perfetto

    Signed-off-by: Chengzhong Wu <cwu631@bloomberg.net>
    PR-URL: https://github.com/nodejs/node/pull/66068
    Reviewed-By: James M Snell <jasnell@gmail.com>
    Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>

diff --git a/.github/workflows/test-linux-perfetto.yml b/.github/workflows/test-linux-perfetto.yml
index e300ee59aa8..f11970e8f6f 100644
--- a/.github/workflows/test-linux-perfetto.yml
+++ b/.github/workflows/test-linux-perfetto.yml
@@ -62,6 +62,9 @@ jobs:
       - name: Build
         working-directory: node
         run: make build-ci -j4 V=1 CONFIG_FLAGS="--error-on-warn --v8-enable-temporal-support --with-perfetto"
+      - name: Get trace_processor
+        working-directory: node
+        run: make tools/perfetto/trace_processor_shell
       - name: Test
         working-directory: node
         run: make test-ci -j1 V=1 TEST_CI_ARGS="-p actions --measure-flakiness 9"
diff --git a/.gitignore b/.gitignore
index 9277cdf090f..1899fdc0900 100644
--- a/.gitignore
+++ b/.gitignore
@@ -116,6 +116,9 @@ tools/*/*.i.tmp
 /tools/eslint/node_modules
 /tools/lint-md/node_modules

+# === Rules for tools/perfetto ===
+/tools/perfetto/trace_processor_shell
+
 # === Rules for test artifacts ===
 /*.tap
 /*.xml
diff --git a/Makefile b/Makefile
index f763684d031..956d9e79a30 100644
--- a/Makefile
+++ b/Makefile
@@ -235,6 +235,7 @@ distclean: ## Remove all build and test artifacts.
 	$(RM) -r node_modules
 	$(RM) -r deps/icu
 	$(RM) -r deps/icu4c*.tgz deps/icu4c*.zip deps/icu-tmp
+	$(RM) tools/perfetto/trace_processor_shell
 	$(RM) $(BINARYTAR).* $(TARBALL).*

 .PHONY: check
@@ -338,6 +339,18 @@ coverage-run-js: ## Run JavaScript tests with coverage.
 					TEST_CI_ARGS="$(TEST_CI_ARGS) --type=coverage" $(MAKE) jstest
 	$(MAKE) coverage-report-js

+TRACE_PROCESSOR_SHELL_PATH ?= tools/perfetto/trace_processor_shell
+
+# The downloaded copy has to match the vendored perfetto, so a version bump
+# re-downloads it. An overridden path is a build we do not manage and may not
+# be writable, so it gets no prerequisite and is left alone once it exists.
+ifeq ($(TRACE_PROCESSOR_SHELL_PATH),tools/perfetto/trace_processor_shell)
+TRACE_PROCESSOR_SHELL_DEPS = deps/perfetto/VERSION
+endif
+
+$(TRACE_PROCESSOR_SHELL_PATH): $(TRACE_PROCESSOR_SHELL_DEPS)
+	@tools/perfetto/get_trace_processor $@
+
 .PHONY: test
 # This does not run tests of third-party libraries inside deps.
 test: all ## Run default tests and build docs.
diff --git a/shell.nix b/shell.nix
index 60bfb1114b4..7cfa46e88bb 100644
--- a/shell.nix
+++ b/shell.nix
@@ -172,6 +172,9 @@ pkgs.mkShell {
   // pkgs.lib.optionalAttrs (!withSQLite) {
     NOSQLITE = "1";
   }
+  // pkgs.lib.optionalAttrs (withPerfetto) {
+    TRACE_PROCESSOR_SHELL_PATH = "${pkgs.perfetto.tools}/bin/trace_processor_shell";
+  }
   // pkgs.lib.optionalAttrs (pkcs11 != false && pkcs11 != null) (
     let
       pkcs11' =
diff --git a/test/common/index.js b/test/common/index.js
index ce083ee7294..a2c7106a528 100755
--- a/test/common/index.js
+++ b/test/common/index.js
@@ -1029,6 +1029,7 @@ const common = {
   hasSQLite,
   hasFFI,
   hasLocalStorage,
+  hasPerfetto,
   invalidArgTypeHelper,
   isAlive,
   isASan,
diff --git a/test/common/trace_events.js b/test/common/trace_events.js
new file mode 100644
index 00000000000..67fc0919350
--- /dev/null
+++ b/test/common/trace_events.js
@@ -0,0 +1,66 @@
+'use strict';
+
+// Helpers to deal with both Chrome legacy JSON trace format, and Perfetto
+// binary format.
+// This depends on perfetto's `trace_processor_shell` converts to pftrace format
+// to JSON format. Run `make tools/perfetto/trace_processor_shell` to download
+// it, or point `TRACE_PROCESSOR_SHELL_PATH` at an existing build of it.
+
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const common = require('./');
+
+const traceProcessor = path.resolve(
+  __dirname, '..', '..',
+  process.env.TRACE_PROCESSOR_SHELL_PATH || 'tools/perfetto/trace_processor_shell');
+
+// The JSON form of a trace runs about three times the size of the trace it was
+// converted from, and the traces these tests produce are a few hundred KiB at
+// most. This is an assumed MAX size of a JSON conversion size limit for tests.
+const kMaxTraceJsonBytes = 64 * 1024 * 1024;
+
+const traceFileExt = common.hasPerfetto ? 'pftrace' : 'log';
+const defaultTraceFileName = `node_trace.1.${traceFileExt}`;
+
+// Only perfetto traces need converting, so a missing `trace_processor_shell`
+// does not stop anything on a legacy build.
+function checkTraceProcessor() {
+  if (common.hasPerfetto && !fs.existsSync(traceProcessor)) {
+    assert.fail('trace_processor_shell is missing, ' +
+                'run `make tools/perfetto/trace_processor_shell` to download it');
+  }
+}
+
+function readTraceEvents(file) {
+  if (!common.hasPerfetto) {
+    return JSON.parse(fs.readFileSync(file, 'utf8')).traceEvents;
+  }
+
+  const converted = spawnSync(traceProcessor, ['convert', 'json', file],
+                              { maxBuffer: kMaxTraceJsonBytes });
+  assert.ifError(converted.error);
+  assert.strictEqual(
+    converted.status, 0,
+    `trace_processor_shell failed: ${converted.stderr}`);
+  return JSON.parse(converted.stdout.toString()).traceEvents;
+}
+
+// A perfetto trace event carries the single category it was emitted with. The
+// legacy backend instead groups it with every ancestor category, so
+// `node.net.native` is recorded as `node,node.net,node.net.native`.
+function traceCategory(name) {
+  if (common.hasPerfetto) {
+    return name;
+  }
+  const parts = name.split('.');
+  return parts.map((_, i) => parts.slice(0, i + 1).join('.')).join(',');
+}
+
+module.exports = {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+};
diff --git a/test/parallel/test-inspector-tracing-domain.js b/test/parallel/test-inspector-tracing-domain.js
index b9e62a483e8..688f618f2f7 100644
--- a/test/parallel/test-inspector-tracing-domain.js
+++ b/test/parallel/test-inspector-tracing-domain.js
@@ -3,6 +3,9 @@
 const common = require('../common');

 common.skipIfInspectorDisabled();
+// The inspector NodeTracing domain is not wired up on a perfetto build, see
+// src/inspector_agent.cc, so every command here fails with
+// ERR_INSPECTOR_COMMAND.
 common.skipIfPerfettoEnabled();

 const { isMainThread } = require('worker_threads');
diff --git a/test/parallel/test-module-print-timing.mjs b/test/parallel/test-module-print-timing.mjs
index 6b0bfee8431..409a497fa90 100644
--- a/test/parallel/test-module-print-timing.mjs
+++ b/test/parallel/test-module-print-timing.mjs
@@ -7,6 +7,8 @@ import tmpdir from '../common/tmpdir.js';
 import { spawnSyncAndAssert } from '../common/child_process.js';
 import fixtures from '../common/fixtures.js';

+// The dynamic tracing case below records nothing on a perfetto build: a
+// category enabled after the tracing session started stays off.
 skipIfPerfettoEnabled();
 tmpdir.refresh();

diff --git a/test/parallel/test-permission-fs-write-trace-events.js b/test/parallel/test-permission-fs-write-trace-events.js
index 37285c3660d..4ca4fede7bb 100644
--- a/test/parallel/test-permission-fs-write-trace-events.js
+++ b/test/parallel/test-permission-fs-write-trace-events.js
@@ -1,11 +1,9 @@
-// Flags: --expose-internals
 'use strict';

 const common = require('../common');
 const { spawnSyncAndExitWithoutError } = require('../common/child_process');
 const { isMainThread } = require('worker_threads');

-common.skipIfPerfettoEnabled();
 if (!isMainThread) {
   common.skip('This test only works on a main thread');
 }
@@ -13,6 +11,7 @@ if (!isMainThread) {
 const assert = require('assert');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');
+const { defaultTraceFileName } = require('../common/trace_events');

 try {
   require('trace_events');
@@ -56,7 +55,7 @@ assert.throws(() => {
 }, common.expectsError({
   code: 'ERR_ACCESS_DENIED',
   permission: 'FileSystemWrite',
-  resource: 'node_trace.1.log',
+  resource: defaultTraceFileName,
 }));

-assert.strictEqual(fs.existsSync('node_trace.1.log'), false);
+assert.strictEqual(fs.existsSync(defaultTraceFileName), false);
diff --git a/test/parallel/test-trace-events-all.js b/test/parallel/test-trace-events-all.js
deleted file mode 100644
index 107a9ca3a00..00000000000
--- a/test/parallel/test-trace-events-all.js
+++ /dev/null
@@ -1,57 +0,0 @@
-'use strict';
-const common = require('../common');
-const assert = require('assert');
-const cp = require('child_process');
-const fs = require('fs');
-
-common.skipIfPerfettoEnabled();
-
-const CODE =
-  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
-
-const tmpdir = require('../common/tmpdir');
-tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
-
-const proc = cp.spawn(process.execPath,
-                      [ '--trace-events-enabled', '-e', CODE ],
-                      { cwd: tmpdir.path });
-
-proc.once('exit', common.mustCall(() => {
-  assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    // V8 trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'v8')
-        return false;
-      if (!trace.name.startsWith('V8.'))
-        return false;
-      return true;
-    }));
-
-    // C++ async_hooks trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node,node.async_hooks')
-        return false;
-      return true;
-    }));
-
-
-    // JavaScript async_hooks trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node,node.async_hooks')
-        return false;
-      if (trace.name !== 'Timeout')
-        return false;
-      return true;
-    }));
-  }));
-}));
diff --git a/test/parallel/test-trace-events-async-hooks-dynamic.js b/test/parallel/test-trace-events-async-hooks-dynamic.js
index b64582633fb..8c6ef26e638 100644
--- a/test/parallel/test-trace-events-async-hooks-dynamic.js
+++ b/test/parallel/test-trace-events-async-hooks-dynamic.js
@@ -10,6 +10,8 @@ try {
   common.skip('missing trace events');
 }

+// Perfetto records nothing for a category enabled after the tracing session
+// started, so there is no dynamic enabling to test there yet.
 common.skipIfPerfettoEnabled();

 const assert = require('assert');
diff --git a/test/parallel/test-trace-events-async-hooks.js b/test/parallel/test-trace-events-async-hooks.js
deleted file mode 100644
index e81d686c228..00000000000
--- a/test/parallel/test-trace-events-async-hooks.js
+++ /dev/null
@@ -1,67 +0,0 @@
-'use strict';
-const common = require('../common');
-const assert = require('assert');
-const cp = require('child_process');
-const fs = require('fs');
-const util = require('util');
-
-common.skipIfPerfettoEnabled();
-
-const CODE =
-  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
-
-const tmpdir = require('../common/tmpdir');
-tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
-
-const proc = cp.spawn(process.execPath,
-                      [ '--trace-event-categories', 'node.async_hooks',
-                        '-e', CODE ],
-                      { cwd: tmpdir.path });
-
-proc.once('exit', common.mustCall(() => {
-  assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    // V8 trace events should be generated.
-    assert(!traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'v8')
-        return false;
-      if (trace.name !== 'V8.ScriptCompiler')
-        return false;
-      return true;
-    }));
-
-    // C++ async_hooks trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node,node.async_hooks')
-        return false;
-      return true;
-    }));
-
-    // JavaScript async_hooks trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node,node.async_hooks')
-        return false;
-      if (trace.name !== 'Timeout')
-        return false;
-      return true;
-    }));
-
-    // Check args in init events
-    const initEvents = traces.filter((trace) => {
-      return (trace.ph === 'b' && !trace.name.includes('_CALLBACK'));
-    });
-    assert.ok(initEvents.every((trace) => {
-      return (trace.args.data.executionAsyncId > 0 &&
-              trace.args.data.triggerAsyncId > 0);
-    }), `Unexpected initEvents format: ${util.inspect(initEvents)}`);
-  }));
-}));
diff --git a/test/parallel/test-trace-events-binding.js b/test/parallel/test-trace-events-binding.js
index b4cba883024..43cd559e4b4 100644
--- a/test/parallel/test-trace-events-binding.js
+++ b/test/parallel/test-trace-events-binding.js
@@ -4,6 +4,8 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');

+// V8's trace() builtin only accepts the begin, end and instant phases on a
+// perfetto build, so the nestable async phase used here throws a TypeError.
 common.skipIfPerfettoEnabled();

 const CODE = `
diff --git a/test/parallel/test-trace-events-console.js b/test/parallel/test-trace-events-console.js
index 4b48695c568..8dc0a39bf9a 100644
--- a/test/parallel/test-trace-events-console.js
+++ b/test/parallel/test-trace-events-console.js
@@ -5,6 +5,9 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

+// console.count() throws on a perfetto build once node.console is enabled:
+// internal/trace_events picks the nestable async instant phase for counters,
+// which V8's trace() builtin rejects there.
 common.skipIfPerfettoEnabled();

 // Tests that node.console trace events for counters and time methods are
diff --git a/test/parallel/test-trace-events-dynamic-enable.js b/test/parallel/test-trace-events-dynamic-enable.js
index af8f9095b00..c9802b46d33 100644
--- a/test/parallel/test-trace-events-dynamic-enable.js
+++ b/test/parallel/test-trace-events-dynamic-enable.js
@@ -4,6 +4,8 @@
 const common = require('../common');

 common.skipIfInspectorDisabled();
+// Needs two things a perfetto build does not have: the inspector NodeTracing
+// domain, and recording for a category enabled after the session started.
 common.skipIfPerfettoEnabled();

 const { isMainThread } = require('worker_threads');
diff --git a/test/parallel/test-trace-events-fs-async.js b/test/parallel/test-trace-events-fs-async.js
index 2a658838b39..30233aeb735 100644
--- a/test/parallel/test-trace-events-fs-async.js
+++ b/test/parallel/test-trace-events-fs-async.js
@@ -5,6 +5,11 @@ const cp = require('child_process');
 const fs = require('fs');
 const util = require('util');

+// MKDir passes UV_FS_UNLINK to FS_ASYNC_TRACE_BEGIN1 (src/node_file.cc), so
+// the begin event is named `unlink` while the end event is named `mkdir`. The
+// legacy backend records both names, and this test passes off the end event.
+// Perfetto matches an async pair by name and drops the unmatched end, leaving
+// no `mkdir` event at all.
 common.skipIfPerfettoEnabled();

 const tests = { __proto__: null };
diff --git a/test/parallel/test-trace-events-get-category-enabled-buffer.js b/test/parallel/test-trace-events-get-category-enabled-buffer.js
index 929f1ae3f2a..d00e32cfc22 100644
--- a/test/parallel/test-trace-events-get-category-enabled-buffer.js
+++ b/test/parallel/test-trace-events-get-category-enabled-buffer.js
@@ -10,6 +10,8 @@ try {
   common.skip('missing trace events');
 }

+// Perfetto aborts on a category that is not in its static registry, and its
+// enabled flag does not follow createTracing().enable().
 common.skipIfPerfettoEnabled();

 const { createTracing, getEnabledCategories } = require('trace_events');
diff --git a/test/parallel/test-trace-events-metadata.js b/test/parallel/test-trace-events-metadata.js
index d92615fb94e..e806a7dda2d 100644
--- a/test/parallel/test-trace-events-metadata.js
+++ b/test/parallel/test-trace-events-metadata.js
@@ -4,6 +4,8 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');

+// A perfetto build drops the legacy metadata events, so none of the
+// process_name, version or node entries checked here are recorded.
 common.skipIfPerfettoEnabled();

 const CODE =
diff --git a/test/parallel/test-trace-events-v8.js b/test/parallel/test-trace-events-v8.js
deleted file mode 100644
index f71c4a057af..00000000000
--- a/test/parallel/test-trace-events-v8.js
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict';
-const common = require('../common');
-const assert = require('assert');
-const cp = require('child_process');
-const fs = require('fs');
-
-common.skipIfPerfettoEnabled();
-
-const CODE =
-  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
-
-const tmpdir = require('../common/tmpdir');
-tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
-
-const proc = cp.spawn(process.execPath,
-                      [ '--trace-events-enabled',
-                        '--trace-event-categories', 'v8',
-                        '-e', CODE ],
-                      { cwd: tmpdir.path });
-
-proc.once('exit', common.mustCall(() => {
-  assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    // V8 trace events should be generated.
-    assert(traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'v8')
-        return false;
-      if (!trace.name.startsWith('V8.'))
-        return false;
-      return true;
-    }));
-
-    // C++ async_hooks trace events should be generated.
-    assert(!traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node.async_hooks')
-        return false;
-      return true;
-    }));
-
-
-    // JavaScript async_hooks trace events should be generated.
-    assert(!traces.some((trace) => {
-      if (trace.pid !== proc.pid)
-        return false;
-      if (trace.cat !== 'node.async_hooks')
-        return false;
-      if (trace.name !== 'Timeout')
-        return false;
-      return true;
-    }));
-  }));
-}));
diff --git a/test/parallel/test-trace-events-worker-metadata-with-name.js b/test/parallel/test-trace-events-worker-metadata-with-name.js
index 0140d08a5f0..1fa3d1e5f79 100644
--- a/test/parallel/test-trace-events-worker-metadata-with-name.js
+++ b/test/parallel/test-trace-events-worker-metadata-with-name.js
@@ -5,6 +5,8 @@ const cp = require('child_process');
 const fs = require('fs');
 const { isMainThread } = require('worker_threads');

+// A perfetto build names a thread through its track descriptor rather than
+// a thread_name metadata event, and without the `[worker <id>]` prefix.
 common.skipIfPerfettoEnabled();

 if (isMainThread) {
diff --git a/test/parallel/test-trace-events-worker-metadata.js b/test/parallel/test-trace-events-worker-metadata.js
index 2e6c20255ce..9e8ebeef500 100644
--- a/test/parallel/test-trace-events-worker-metadata.js
+++ b/test/parallel/test-trace-events-worker-metadata.js
@@ -5,6 +5,8 @@ const cp = require('child_process');
 const fs = require('fs');
 const { isMainThread } = require('worker_threads');

+// A perfetto build names a thread through its track descriptor rather than
+// a thread_name metadata event, and without the `[worker <id>]` prefix.
 common.skipIfPerfettoEnabled();

 if (isMainThread) {
diff --git a/test/trace_events/README.md b/test/trace_events/README.md
new file mode 100644
index 00000000000..756dd9aeba6
--- /dev/null
+++ b/test/trace_events/README.md
@@ -0,0 +1,11 @@
+# `trace_events` Tests
+
+When the `node` binary is built with configure flag `--with-perfetto`,
+the tests in this folder depends on `tools/perfetto/trace_processor_shell`,
+which is downloaded with `tools/perfetto/get_trace_processor` via
+`make tools/perfetto/trace_processor_shell`, to convert Perfetto binary trace
+files to JSON format. Set `TRACE_PROCESSOR_SHELL_PATH` to use an existing build
+of the tool instead of downloading a release.
+
+Refer to <https://perfetto.dev/docs/reference/trace-processor-cli>
+for help of the `trace_processor_shell` CLI.
diff --git a/test/trace_events/test-trace-events-all.js b/test/trace_events/test-trace-events-all.js
new file mode 100644
index 00000000000..fe422d03a77
--- /dev/null
+++ b/test/trace_events/test-trace-events-all.js
@@ -0,0 +1,62 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const cp = require('child_process');
+const fs = require('fs');
+
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();
+
+const CODE =
+  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);
+
+const proc = cp.spawn(process.execPath,
+                      [ '--trace-events-enabled', '-e', CODE ],
+                      { cwd: tmpdir.path });
+
+proc.once('exit', common.mustCall(() => {
+  assert(fs.existsSync(FILE_NAME));
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  // V8 trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== 'v8')
+      return false;
+    if (!trace.name.startsWith('V8.'))
+      return false;
+    return true;
+  }));
+
+  // C++ async_hooks trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    return true;
+  }));
+
+
+  // JavaScript async_hooks trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    if (trace.name !== 'Timeout')
+      return false;
+    return true;
+  }));
+}));
diff --git a/test/parallel/test-trace-events-api.js b/test/trace_events/test-trace-events-api.js
similarity index 69%
rename from test/parallel/test-trace-events-api.js
rename to test/trace_events/test-trace-events-api.js
index dcbc0ac237e..6d50213d775 100644
--- a/test/parallel/test-trace-events-api.js
+++ b/test/trace_events/test-trace-events-api.js
@@ -4,8 +4,6 @@
 const common = require('../common');
 const { isMainThread } = require('worker_threads');

-common.skipIfPerfettoEnabled();
-
 if (!isMainThread) {
   // https://github.com/nodejs/node/issues/22767
   common.skip('This test only works on a main thread');
@@ -21,11 +19,18 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
 const {
   createTracing,
-  getEnabledCategories
+  getEnabledCategories,
 } = require('trace_events');

+checkTraceProcessor();
+
 function getEnabledCategoriesFromCommandLine() {
   const indexOfCatFlag = process.execArgv.indexOf('--trace-event-categories');
   if (indexOfCatFlag === -1) {
@@ -41,11 +46,11 @@ assert.strictEqual(getEnabledCategories(), enabledCategories);
 for (const i of [1, 'foo', true, false, null, undefined]) {
   assert.throws(() => createTracing(i), {
     code: 'ERR_INVALID_ARG_TYPE',
-    name: 'TypeError'
+    name: 'TypeError',
   });
   assert.throws(() => createTracing({ categories: i }), {
     code: 'ERR_INVALID_ARG_TYPE',
-    name: 'TypeError'
+    name: 'TypeError',
   });
 }

@@ -53,8 +58,8 @@ assert.throws(
   () => createTracing({ categories: [] }),
   {
     code: 'ERR_TRACE_EVENTS_CATEGORY_REQUIRED',
-    name: 'TypeError'
-  }
+    name: 'TypeError',
+  },
 );

 const tracing = createTracing({ categories: [ 'node.perf' ] });
@@ -84,22 +89,19 @@ tracing2.disable();  // Purposefully disable twice to test calling twice
 assert.strictEqual(getEnabledCategories(), enabledCategories);

 if (isChild) {
-  const { internalBinding } = require('internal/test/binding');
-
+  // Perfetto only accepts the synchronous begin/end phases, so take the phase
+  // constants from internal/trace_events, which picks the right pair.
   const {
-    trace: {
-      TRACE_EVENT_PHASE_NESTABLE_ASYNC_BEGIN: kBeforeEvent,
-      TRACE_EVENT_PHASE_NESTABLE_ASYNC_END: kEndEvent,
-    }
-  } = internalBinding('constants');
-
-  const { trace } = internalBinding('trace_events');
+    trace,
+    kAsyncBegin,
+    kAsyncEnd,
+  } = require('internal/trace_events');

   tracing.enable();

-  trace(kBeforeEvent, 'foo', 'test1', 0, 'test');
+  trace(kAsyncBegin, 'foo', 'test1', 0, 'test');
   setTimeout(() => {
-    trace(kEndEvent, 'foo', 'test1');
+    trace(kAsyncEnd, 'foo', 'test1');
   }, 1);
 } else {
   // Test that enabled tracing references do not get garbage collected
@@ -138,8 +140,9 @@ function testApiInChildProcess(execArgs, cb) {
   const parentDir = process.cwd();
   process.chdir(tmpdir.path);

-  const expectedBegins = [{ cat: 'foo', name: 'test1' }];
-  const expectedEnds = [{ cat: 'foo', name: 'test1' }];
+  // The child emits one begin/end pair. Perfetto merges a pair into a single
+  // complete event when the trace is converted back.
+  const expectedPhases = common.hasPerfetto ? ['X'] : ['b', 'e'];

   const proc = cp.fork(__filename,
                        ['child'],
@@ -149,41 +152,24 @@ function testApiInChildProcess(execArgs, cb) {
                            '--expose-internals',
                            '--no-warnings',
                            ...execArgs,
-                         ]
+                         ],
                        });

   proc.once('exit', common.mustCall(() => {
-    const file = tmpdir.resolve('node_trace.1.log');
-
+    const file = tmpdir.resolve(defaultTraceFileName);
     assert(fs.existsSync(file));
-    fs.readFile(file, common.mustSucceed((data) => {
-      const traces = JSON.parse(data.toString()).traceEvents
-        .filter((trace) => trace.cat !== '__metadata');
-
-      assert.strictEqual(
-        traces.length,
-        expectedBegins.length + expectedEnds.length);
-      for (const trace of traces) {
-        assert.strictEqual(trace.pid, proc.pid);
-        switch (trace.ph) {
-          case 'b': {
-            const expectedBegin = expectedBegins.shift();
-            assert.strictEqual(trace.cat, expectedBegin.cat);
-            assert.strictEqual(trace.name, expectedBegin.name);
-            break;
-          }
-          case 'e': {
-            const expectedEnd = expectedEnds.shift();
-            assert.strictEqual(trace.cat, expectedEnd.cat);
-            assert.strictEqual(trace.name, expectedEnd.name);
-            break;
-          }
-          default:
-            assert.fail('Unexpected trace event phase');
-        }
-      }
-      process.chdir(parentDir);
-      cb && process.nextTick(cb);
-    }));
+
+    const traces = readTraceEvents(file)
+      .filter((trace) => trace.cat !== '__metadata');
+
+    assert.deepStrictEqual(traces.map((trace) => trace.ph), expectedPhases);
+    for (const trace of traces) {
+      assert.strictEqual(trace.pid, proc.pid);
+      assert.strictEqual(trace.cat, 'foo');
+      assert.strictEqual(trace.name, 'test1');
+    }
+
+    process.chdir(parentDir);
+    cb && process.nextTick(cb);
   }));
 }
diff --git a/test/parallel/test-trace-events-async-hooks-worker.js b/test/trace_events/test-trace-events-async-hooks-worker.js
similarity index 83%
rename from test/parallel/test-trace-events-async-hooks-worker.js
rename to test/trace_events/test-trace-events-async-hooks-worker.js
index f4c56653e28..4fdeb2138eb 100644
--- a/test/parallel/test-trace-events-async-hooks-worker.js
+++ b/test/trace_events/test-trace-events-async-hooks-worker.js
@@ -10,11 +10,17 @@ try {
   common.skip('missing trace events');
 }

-common.skipIfPerfettoEnabled();
-
 const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const code =
   'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
@@ -29,7 +35,7 @@ worker.stderr.on('data',
 worker.on('exit', () => { ${code} })`;

 const tmpdir = require('../common/tmpdir');
-const filename = tmpdir.resolve('node_trace.1.log');
+const filename = tmpdir.resolve(defaultTraceFileName);

 tmpdir.refresh();
 const proc = cp.spawnSync(
@@ -39,7 +45,7 @@ const proc = cp.spawnSync(
     cwd: tmpdir.path,
     env: { ...process.env,
            'NODE_DEBUG_NATIVE': 'tracing',
-           'NODE_DEBUG': 'tracing' }
+           'NODE_DEBUG': 'tracing' },
   });

 console.log('process exit with signal:', proc.signal);
@@ -47,13 +53,12 @@ console.log('process stderr:', proc.stderr.toString());

 assert.strictEqual(proc.status, 0);
 assert(fs.existsSync(filename));
-const data = fs.readFileSync(filename, 'utf-8');
-const traces = JSON.parse(data).traceEvents;
+const traces = readTraceEvents(filename);

 function filterTimeoutTraces(trace) {
   if (trace.pid !== proc.pid)
     return false;
-  if (trace.cat !== 'node,node.async_hooks')
+  if (trace.cat !== traceCategory('node.async_hooks'))
     return false;
   if (trace.name !== 'Timeout')
     return false;
diff --git a/test/trace_events/test-trace-events-async-hooks.js b/test/trace_events/test-trace-events-async-hooks.js
new file mode 100644
index 00000000000..c404402594a
--- /dev/null
+++ b/test/trace_events/test-trace-events-async-hooks.js
@@ -0,0 +1,74 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const cp = require('child_process');
+const fs = require('fs');
+const util = require('util');
+
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();
+
+const CODE =
+  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);
+
+const proc = cp.spawn(process.execPath,
+                      [ '--trace-event-categories', 'node.async_hooks',
+                        '-e', CODE ],
+                      { cwd: tmpdir.path });
+
+proc.once('exit', common.mustCall(() => {
+  assert(fs.existsSync(FILE_NAME));
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  // V8 trace events should not be generated.
+  assert(!traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== 'v8')
+      return false;
+    if (trace.name !== 'V8.ScriptCompiler')
+      return false;
+    return true;
+  }));
+
+  // C++ async_hooks trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    return true;
+  }));
+
+  // JavaScript async_hooks trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    if (trace.name !== 'Timeout')
+      return false;
+    return true;
+  }));
+
+  // Check args in init events. Perfetto records the begin/end pair of an
+  // async_hooks event as one complete event.
+  const initPhase = common.hasPerfetto ? 'X' : 'b';
+  const initEvents = traces.filter((trace) => {
+    return (trace.ph === initPhase && !trace.name.includes('_CALLBACK'));
+  });
+  assert.ok(initEvents.every((trace) => {
+    return (trace.args.data.executionAsyncId > 0 &&
+            trace.args.data.triggerAsyncId > 0);
+  }), `Unexpected initEvents format: ${util.inspect(initEvents)}`);
+}));
diff --git a/test/parallel/test-trace-events-bootstrap.js b/test/trace_events/test-trace-events-bootstrap.js
similarity index 64%
rename from test/parallel/test-trace-events-bootstrap.js
rename to test/trace_events/test-trace-events-bootstrap.js
index 56a35ee8c75..dfead479bb7 100644
--- a/test/parallel/test-trace-events-bootstrap.js
+++ b/test/trace_events/test-trace-events-bootstrap.js
@@ -5,7 +5,13 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const names = [
   'environment',
@@ -27,20 +33,18 @@ if (process.argv[2] === 'child') {
                          execArgv: [
                            '--trace-event-categories',
                            'node.bootstrap',
-                         ]
+                         ],
                        });

   proc.once('exit', common.mustCall(() => {
-    const file = tmpdir.resolve('node_trace.1.log');
+    const file = tmpdir.resolve(defaultTraceFileName);

     assert(fs.existsSync(file));
-    fs.readFile(file, common.mustSucceed((data) => {
-      const traces = JSON.parse(data.toString()).traceEvents
-        .filter((trace) => trace.cat !== '__metadata');
-      traces.forEach((trace) => {
-        assert.strictEqual(trace.pid, proc.pid);
-        assert(names.includes(trace.name));
-      });
-    }));
+    const traces = readTraceEvents(file)
+      .filter((trace) => trace.cat !== '__metadata');
+    traces.forEach((trace) => {
+      assert.strictEqual(trace.pid, proc.pid);
+      assert(names.includes(trace.name));
+    });
   }));
 }
diff --git a/test/parallel/test-trace-events-environment.js b/test/trace_events/test-trace-events-environment.js
similarity index 82%
rename from test/parallel/test-trace-events-environment.js
rename to test/trace_events/test-trace-events-environment.js
index 46de418caf1..07561e58a94 100644
--- a/test/parallel/test-trace-events-environment.js
+++ b/test/trace_events/test-trace-events-environment.js
@@ -7,7 +7,13 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 // This tests the emission of node.environment trace events

@@ -39,16 +45,15 @@ if (process.argv[2] === 'child') {
                          execArgv: [
                            '--trace-event-categories',
                            'node.environment',
-                         ]
+                         ],
                        });

-  proc.once('exit', common.mustCall(async () => {
-    const file = tmpdir.resolve('node_trace.1.log');
+  proc.once('exit', common.mustCall(() => {
+    const file = tmpdir.resolve(defaultTraceFileName);
     const checkSet = new Set();

     assert(fs.existsSync(file));
-    const data = await fs.promises.readFile(file);
-    for (const trace of JSON.parse(data.toString()).traceEvents
+    for (const trace of readTraceEvents(file)
       .filter((trace) => trace.cat !== '__metadata')) {
       assert.strictEqual(trace.pid, proc.pid);
       assert(names.has(trace.name));
diff --git a/test/parallel/test-trace-events-file-pattern.js b/test/trace_events/test-trace-events-file-pattern.js
similarity index 79%
rename from test/parallel/test-trace-events-file-pattern.js
rename to test/trace_events/test-trace-events-file-pattern.js
index 3e6e339173f..4b053aeb60c 100644
--- a/test/parallel/test-trace-events-file-pattern.js
+++ b/test/trace_events/test-trace-events-file-pattern.js
@@ -5,7 +5,12 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');

-common.skipIfPerfettoEnabled();
+const {
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 tmpdir.refresh();

@@ -24,8 +29,5 @@ proc.once('exit', common.mustCall(() => {
   const expectedFilename = tmpdir.resolve(`${proc.pid}-1-${proc.pid}-1.tracing.log`);

   assert(fs.existsSync(expectedFilename));
-  fs.readFile(expectedFilename, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-  }));
+  assert(readTraceEvents(expectedFilename).length > 0);
 }));
diff --git a/test/parallel/test-trace-events-fs-sync.js b/test/trace_events/test-trace-events-fs-sync.js
similarity index 95%
rename from test/parallel/test-trace-events-fs-sync.js
rename to test/trace_events/test-trace-events-fs-sync.js
index 800d1a83b64..0fddb93dfda 100644
--- a/test/parallel/test-trace-events-fs-sync.js
+++ b/test/trace_events/test-trace-events-fs-sync.js
@@ -5,7 +5,14 @@ const cp = require('child_process');
 const fs = require('fs');
 const util = require('util');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const tests = { __proto__: null };

@@ -117,7 +124,7 @@ if (common.canCreateSymLink()) {

 const tmpdir = require('../common/tmpdir');
 tmpdir.refresh();
-const traceFile = tmpdir.resolve('node_trace.1.log');
+const traceFile = tmpdir.resolve(defaultTraceFileName);

 for (const tr in tests) {
   const proc = cp.spawnSync(process.execPath,
@@ -136,15 +143,14 @@ for (const tr in tests) {

   // Confirm that trace log file is created.
   assert(fs.existsSync(traceFile));
-  const data = fs.readFileSync(traceFile);
-  const traces = JSON.parse(data.toString()).traceEvents;
+  const traces = readTraceEvents(traceFile);
   assert(traces.length > 0);

   // C++ fs sync trace events should be generated.
   assert(traces.some((trace) => {
     if (trace.pid !== proc.pid)
       return false;
-    if (trace.cat !== 'node,node.fs,node.fs.sync')
+    if (trace.cat !== traceCategory('node.fs.sync'))
       return false;
     if (trace.name !== tr)
       return false;
diff --git a/test/parallel/test-trace-events-http.js b/test/trace_events/test-trace-events-http.js
similarity index 55%
rename from test/parallel/test-trace-events-http.js
rename to test/trace_events/test-trace-events-http.js
index 47ac4dff9b3..32c8bc7d167 100644
--- a/test/parallel/test-trace-events-http.js
+++ b/test/trace_events/test-trace-events-http.js
@@ -5,7 +5,14 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const CODE = `
   const http = require('http');
@@ -18,7 +25,7 @@ const CODE = `
 `;

 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 const proc = cp.spawn(process.execPath,
                       [ '--trace-events-enabled',
@@ -28,18 +35,15 @@ const proc = cp.spawn(process.execPath,

 proc.once('exit', common.mustCall(() => {
   assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    assert(!err);
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    let count = 0;
-    for (const trace of traces) {
-      if (trace.cat === 'node,node.http' &&
-          ['http.server.request', 'http.client.request'].includes(trace.name)) {
-        count++;
-      }
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  let count = 0;
+  for (const trace of traces) {
+    if (trace.cat === traceCategory('node.http') &&
+        ['http.server.request', 'http.client.request'].includes(trace.name)) {
+      count++;
     }
-    // Two begin, two end
-    assert.strictEqual(count, 4);
-  }));
+  }
+  // Two begin and two end, which perfetto records as two complete events.
+  assert.strictEqual(count, common.hasPerfetto ? 2 : 4);
 }));
diff --git a/test/parallel/test-trace-events-net-abstract-socket.js b/test/trace_events/test-trace-events-net-abstract-socket.js
similarity index 55%
rename from test/parallel/test-trace-events-net-abstract-socket.js
rename to test/trace_events/test-trace-events-net-abstract-socket.js
index 9505fb214ad..4f933be8dd8 100644
--- a/test/parallel/test-trace-events-net-abstract-socket.js
+++ b/test/trace_events/test-trace-events-net-abstract-socket.js
@@ -5,8 +5,15 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
 if (!common.isLinux) common.skip();
-common.skipIfPerfettoEnabled();
+checkTraceProcessor();

 const CODE = `
   const net = require('net');
@@ -15,7 +22,7 @@ const CODE = `
 `;

 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 const proc = cp.spawn(process.execPath,
                       [ '--trace-events-enabled',
@@ -25,20 +32,18 @@ const proc = cp.spawn(process.execPath,

 proc.once('exit', common.mustCall(() => {
   assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    let count = 0;
-    traces.forEach((trace) => {
-      if (trace.cat === 'node,node.net,node.net.native' &&
-          trace.name === 'connect') {
-        count++;
-        if (trace.ph === 'b') {
-          assert.ok(!!trace.args.path_type);
-          assert.ok(!!trace.args.pipe_path);
-        }
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  let count = 0;
+  traces.forEach((trace) => {
+    if (trace.cat === traceCategory('node.net.native') &&
+        trace.name === 'connect') {
+      count++;
+      if (trace.ph === 'b') {
+        assert.ok(!!trace.args.path_type);
+        assert.ok(!!trace.args.pipe_path);
       }
-    });
-    assert.strictEqual(count, 4);
-  }));
+    }
+  });
+  assert.strictEqual(count, 4);
 }));
diff --git a/test/parallel/test-trace-events-net.js b/test/trace_events/test-trace-events-net.js
similarity index 62%
rename from test/parallel/test-trace-events-net.js
rename to test/trace_events/test-trace-events-net.js
index 98a5167f957..90d501984aa 100644
--- a/test/parallel/test-trace-events-net.js
+++ b/test/trace_events/test-trace-events-net.js
@@ -5,7 +5,14 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const CODE = `
   const net = require('net');
@@ -20,7 +27,7 @@ const CODE = `
 `;

 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 const proc = cp.spawn(process.execPath,
                       [ '--trace-events-enabled',
@@ -30,16 +37,15 @@ const proc = cp.spawn(process.execPath,

 proc.once('exit', common.mustCall(() => {
   assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-    let count = 0;
-    for (const trace of traces) {
-      if (trace.cat === 'node,node.net,node.net.native' && trace.name === 'connect') {
-        count++;
-      }
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  let count = 0;
+  for (const trace of traces) {
+    if (trace.cat === traceCategory('node.net.native') &&
+        trace.name === 'connect') {
+      count++;
     }
-    // Two begin, two end
-    assert.strictEqual(count, 4);
-  }));
+  }
+  // Two begin, two end
+  assert.strictEqual(count, 4);
 }));
diff --git a/test/parallel/test-trace-events-none.js b/test/trace_events/test-trace-events-none.js
similarity index 65%
rename from test/parallel/test-trace-events-none.js
rename to test/trace_events/test-trace-events-none.js
index 641787b3b56..dd2a47e9413 100644
--- a/test/parallel/test-trace-events-none.js
+++ b/test/trace_events/test-trace-events-none.js
@@ -4,26 +4,30 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const CODE =
   'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';

 const tmpdir = require('../common/tmpdir');
 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 const proc_no_categories = cp.spawn(
   process.execPath,
   [ '--trace-event-categories', '""', '-e', CODE ],
-  { cwd: tmpdir.path }
+  { cwd: tmpdir.path },
 );

 proc_no_categories.once('exit', common.mustCall(() => {
   assert(fs.existsSync(FILE_NAME));
   // Only __metadata categories should have been emitted.
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    assert.ok(JSON.parse(data.toString()).traceEvents.every(
-      (trace) => trace.cat === '__metadata'));
-  }));
+  assert.ok(readTraceEvents(FILE_NAME).every(
+    (trace) => trace.cat === '__metadata'));
 }));
diff --git a/test/parallel/test-trace-events-process-exit.js b/test/trace_events/test-trace-events-process-exit.js
similarity index 66%
rename from test/parallel/test-trace-events-process-exit.js
rename to test/trace_events/test-trace-events-process-exit.js
index c4066809f3f..cb09c1e3cdc 100644
--- a/test/parallel/test-trace-events-process-exit.js
+++ b/test/trace_events/test-trace-events-process-exit.js
@@ -4,11 +4,17 @@ const assert = require('assert');
 const cp = require('child_process');
 const fs = require('fs');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const tmpdir = require('../common/tmpdir');
 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 const proc = cp.spawn(process.execPath,
                       [ '--trace-events-enabled',
@@ -17,8 +23,5 @@ const proc = cp.spawn(process.execPath,

 proc.once('exit', common.mustCall(() => {
   assert(fs.existsSync(FILE_NAME));
-  fs.readFile(FILE_NAME, common.mustCall((err, data) => {
-    const traces = JSON.parse(data.toString()).traceEvents;
-    assert(traces.length > 0);
-  }));
+  assert(readTraceEvents(FILE_NAME).length > 0);
 }));
diff --git a/test/parallel/test-trace-events-promises.js b/test/trace_events/test-trace-events-promises.js
similarity index 60%
rename from test/parallel/test-trace-events-promises.js
rename to test/trace_events/test-trace-events-promises.js
index 4626ed9f08a..d633d55cab3 100644
--- a/test/parallel/test-trace-events-promises.js
+++ b/test/trace_events/test-trace-events-promises.js
@@ -5,7 +5,13 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 if (process.argv[2] === 'child') {
   const p = Promise.reject(1);  // Handled later
@@ -23,22 +29,20 @@ if (process.argv[2] === 'child') {
                            '--no-warnings',
                            '--trace-event-categories',
                            'node.promises.rejections',
-                         ]
+                         ],
                        });

   proc.once('exit', common.mustCall(() => {
-    const file = tmpdir.resolve('node_trace.1.log');
+    const file = tmpdir.resolve(defaultTraceFileName);

     assert(fs.existsSync(file));
-    fs.readFile(file, common.mustCall((err, data) => {
-      const traces = JSON.parse(data.toString()).traceEvents
-        .filter((trace) => trace.cat !== '__metadata');
-      traces.forEach((trace) => {
-        assert.strictEqual(trace.pid, proc.pid);
-        assert.strictEqual(trace.name, 'rejections');
-        assert(trace.args.unhandled <= 2);
-        assert(trace.args.handledAfter <= 1);
-      });
-    }));
+    const traces = readTraceEvents(file)
+      .filter((trace) => trace.cat !== '__metadata');
+    traces.forEach((trace) => {
+      assert.strictEqual(trace.pid, proc.pid);
+      assert.strictEqual(trace.name, 'rejections');
+      assert(trace.args.unhandled <= 2);
+      assert(trace.args.handledAfter <= 1);
+    });
   }));
 }
diff --git a/test/parallel/test-trace-events-threadpool.js b/test/trace_events/test-trace-events-threadpool.js
similarity index 70%
rename from test/parallel/test-trace-events-threadpool.js
rename to test/trace_events/test-trace-events-threadpool.js
index 36d3b306d0e..d0cfeff7a86 100644
--- a/test/parallel/test-trace-events-threadpool.js
+++ b/test/trace_events/test-trace-events-threadpool.js
@@ -6,11 +6,18 @@ const fs = require('fs');
 const tmpdir = require('../common/tmpdir');
 const { scheduler } = require('timers/promises');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');

 if (!common.hasCrypto)
   common.skip('missing crypto');

+checkTraceProcessor();
+
 const { hkdf } = require('crypto');
 const { deflate } = require('zlib');

@@ -22,7 +29,7 @@ if (process.env.isChild === '1') {
 }

 tmpdir.refresh();
-const FILE_NAME = tmpdir.resolve('node_trace.1.log');
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);

 cp.spawnSync(process.execPath,
              [
@@ -40,8 +47,7 @@ cp.spawnSync(process.execPath,
              });

 assert(fs.existsSync(FILE_NAME));
-const data = fs.readFileSync(FILE_NAME);
-const traces = JSON.parse(data.toString()).traceEvents;
+const traces = readTraceEvents(FILE_NAME);

 assert(traces.length > 0);

@@ -50,8 +56,8 @@ let cryptoCount = 0;

 traces.forEach((item) => {
   if ([
-    'node,node.threadpoolwork,node.threadpoolwork.sync',
-    'node,node.threadpoolwork,node.threadpoolwork.async',
+    traceCategory('node.threadpoolwork.sync'),
+    traceCategory('node.threadpoolwork.async'),
   ].includes(item.cat)) {
     if (item.name === 'zlib') {
       zlibCount++;
@@ -61,6 +67,8 @@ traces.forEach((item) => {
   }
 });

-// There are two types, each type has two async events and sync events at least
-assert.ok(zlibCount >= 4);
-assert.ok(cryptoCount >= 4);
+// There are two types, each type has two async events and sync events at
+// least. Perfetto records the sync begin/end pair as one complete event.
+const expected = common.hasPerfetto ? 3 : 4;
+assert.ok(zlibCount >= expected);
+assert.ok(cryptoCount >= expected);
diff --git a/test/trace_events/test-trace-events-v8.js b/test/trace_events/test-trace-events-v8.js
new file mode 100644
index 00000000000..d6c3821407e
--- /dev/null
+++ b/test/trace_events/test-trace-events-v8.js
@@ -0,0 +1,64 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const cp = require('child_process');
+const fs = require('fs');
+
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+  traceCategory,
+} = require('../common/trace_events');
+
+checkTraceProcessor();
+
+const CODE =
+  'setTimeout(() => { for (let i = 0; i < 100000; i++) { "test" + i } }, 1)';
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const FILE_NAME = tmpdir.resolve(defaultTraceFileName);
+
+const proc = cp.spawn(process.execPath,
+                      [ '--trace-events-enabled',
+                        '--trace-event-categories', 'v8',
+                        '-e', CODE ],
+                      { cwd: tmpdir.path });
+
+proc.once('exit', common.mustCall(() => {
+  assert(fs.existsSync(FILE_NAME));
+  const traces = readTraceEvents(FILE_NAME);
+  assert(traces.length > 0);
+  // V8 trace events should be generated.
+  assert(traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== 'v8')
+      return false;
+    if (!trace.name.startsWith('V8.'))
+      return false;
+    return true;
+  }));
+
+  // C++ async_hooks trace events should not be generated.
+  assert(!traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    return true;
+  }));
+
+
+  // JavaScript async_hooks trace events should not be generated.
+  assert(!traces.some((trace) => {
+    if (trace.pid !== proc.pid)
+      return false;
+    if (trace.cat !== traceCategory('node.async_hooks'))
+      return false;
+    if (trace.name !== 'Timeout')
+      return false;
+    return true;
+  }));
+}));
diff --git a/test/parallel/test-trace-events-vm.js b/test/trace_events/test-trace-events-vm.js
similarity index 62%
rename from test/parallel/test-trace-events-vm.js
rename to test/trace_events/test-trace-events-vm.js
index d2156245ab5..c2b9d95e0f0 100644
--- a/test/parallel/test-trace-events-vm.js
+++ b/test/trace_events/test-trace-events-vm.js
@@ -5,7 +5,13 @@ const cp = require('child_process');
 const fs = require('fs');
 const tmpdir = require('../common/tmpdir');

-common.skipIfPerfettoEnabled();
+const {
+  defaultTraceFileName,
+  readTraceEvents,
+  checkTraceProcessor,
+} = require('../common/trace_events');
+
+checkTraceProcessor();

 const names = [
   'ContextifyScript::New',
@@ -24,20 +30,18 @@ if (process.argv[2] === 'child') {
                          execArgv: [
                            '--trace-event-categories',
                            'node.vm.script',
-                         ]
+                         ],
                        });

   proc.once('exit', common.mustCall(() => {
-    const file = tmpdir.resolve('node_trace.1.log');
+    const file = tmpdir.resolve(defaultTraceFileName);

     assert(fs.existsSync(file));
-    fs.readFile(file, common.mustCall((err, data) => {
-      const traces = JSON.parse(data.toString()).traceEvents
-        .filter((trace) => trace.cat !== '__metadata');
-      for (const trace of traces) {
-        assert.strictEqual(trace.pid, proc.pid);
-        assert(names.includes(trace.name));
-      }
-    }));
+    const traces = readTraceEvents(file)
+      .filter((trace) => trace.cat !== '__metadata');
+    for (const trace of traces) {
+      assert.strictEqual(trace.pid, proc.pid);
+      assert(names.includes(trace.name));
+    }
   }));
 }
diff --git a/test/trace_events/testcfg.py b/test/trace_events/testcfg.py
new file mode 100644
index 00000000000..e62bbff517f
--- /dev/null
+++ b/test/trace_events/testcfg.py
@@ -0,0 +1,6 @@
+import sys, os
+sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
+import testpy
+
+def GetConfiguration(context, root):
+  return testpy.ParallelTestConfiguration(context, root, 'trace_events')
diff --git a/test/trace_events/trace_events.status b/test/trace_events/trace_events.status
new file mode 100644
index 00000000000..03423cb74a0
--- /dev/null
+++ b/test/trace_events/trace_events.status
@@ -0,0 +1,7 @@
+prefix trace_events
+
+# To mark a test as flaky, list the test name in the appropriate section
+# below, without ".js", followed by ": PASS,FLAKY". Example:
+# sample-test                        : PASS,FLAKY
+
+[true] # This section applies to all platforms
diff --git a/tools/perfetto/get_trace_processor b/tools/perfetto/get_trace_processor
new file mode 100755
index 00000000000..f00fee8b21e
--- /dev/null
+++ b/tools/perfetto/get_trace_processor
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Downloads perfetto's trace_processor_shell, matching the SDK version vendored
+# in deps/perfetto. The trace events tests read back the binary traces that
+# perfetto builds write through this tool.
+set -e
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+version=$(cat "$script_dir/../../deps/perfetto/VERSION")
+trace_processor="${1:-$script_dir/trace_processor_shell}"
+
+# Perfetto names its release archives <os>-<arch>, which does not match uname.
+case "$(uname -s)" in
+  Darwin) os=mac ;;
+  Linux) os=linux ;;
+  *) echo "No perfetto release for $(uname -s)" >&2; exit 1 ;;
+esac
+
+case "$(uname -m)" in
+  x86_64 | amd64) arch=amd64 ;;
+  arm64 | aarch64) arch=arm64 ;;
+  *) echo "No perfetto release for $(uname -m)" >&2; exit 1 ;;
+esac
+
+url="https://github.com/google/perfetto/releases/download/v$version/$os-$arch.zip"
+archive=$(mktemp)
+
+rm -f "$trace_processor"
+echo "Downloading $url"
+curl -sSfL -o "$archive" "$url"
+unzip -q -j -o "$archive" '*/trace_processor_shell' -d "$script_dir"
+rm -f "$archive"
+chmod +x "$trace_processor"
+# The archive carries the release's own mtime. Make decides whether the tool is
+# current by comparing it against deps/perfetto/VERSION, so it has to come out
+# newer than the version file it was downloaded for.
+touch "$trace_processor"