Skip to content

perf_hooks: make observed net/http entries real PerformanceEntry objects - #33471

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4439bc82/perf-hooks-node-entries
Closed

robobun wants to merge 2 commits into
mainfrom
farm/4439bc82/perf-hooks-node-entries

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

The net and http entries Bun hands to a PerformanceObserver are plain object literals. They have no toJSON(), and entry instanceof PerformanceEntry is false. In Node every observed entry is a real PerformanceEntry. Serializing what you observe is the whole point of observing it, so an observer that calls entry.toJSON() throws, and because observer callbacks swallow exceptions the metrics just disappear.

import http from "node:http";
import { PerformanceObserver, PerformanceEntry } from "node:perf_hooks";

const seen = [];
new PerformanceObserver(l => seen.push(...l.getEntries())).observe({ entryTypes: ["net", "http"] });

const srv = http.createServer((q, s) => s.end("ok")).listen(0, "127.0.0.1", () => {
  http.get({ port: srv.address().port, host: "127.0.0.1" }, r =>
    r.resume().on("end", () => setImmediate(() => setImmediate(() => {
      console.log(seen.map(e => [e.entryType, typeof e.toJSON, e instanceof PerformanceEntry]));
      seen.forEach(e => e.toJSON());
      srv.close();
    }))));
});
node: [ [ 'net', 'function', true ], [ 'http', 'function', true ], [ 'http', 'function', true ] ]
bun:  [ [ 'net', 'undefined', false ], [ 'http', 'undefined', false ], [ 'http', 'undefined', false ] ]
      TypeError: e.toJSON is not a function. (In 'e.toJSON()', 'e.toJSON' is undefined)

Cause

The native (WebCore) PerformanceObserver only knows mark/measure/resource, so Bun routes the Node-only entry types through a small JS registry in internal/shared.ts. stopPerf() built each entry as an object literal:

const entry = { name: ctx.name, entryType: ctx.type, startTime, duration, detail };

Fix

Add internal/perf_hooks/performance_entry, which mirrors lib/internal/perf/performance_entry.js: a PerformanceNodeEntry holding its fields on symbols behind name/entryType/startTime/duration/detail accessors, with toJSON() and a util.inspect.custom that prints the serialized entry. The native PerformanceEntry has no public constructor, so extends would make super() throw; the prototype chain is wired up with setPrototypeOf, the same way Node does it for its own internal entries. stopPerf() now constructs one of these.

Result matches Node's observable shape: instanceof PerformanceEntry, constructor.name === "PerformanceNodeEntry", own properties [], prototype parent === PerformanceEntry.prototype, working toJSON() and JSON.stringify().

PerformanceNodeEntry {
  name: 'connect',
  entryType: 'net',
  startTime: 3301.649018,
  duration: 156.97961799999985,
  detail: { host: '127.0.0.1', port: 35195 }
}

performance.nodeTiming has the same symptom (nodeTiming.name throws the same PerformanceEntry.name getter can only be used on instances of PerformanceEntry) but a different cause: $toClass replaces a class declaration's prototype object with an empty one, dropping the class body's accessors. That is already fixed at the $toClass level by #32163, so it is left alone here.

Verification

test/js/node/perf_hooks/perf_hooks.test.ts grows a test that observes net + http, asserts every entry is a PerformanceEntry with a toJSON() whose output round-trips through JSON.stringify, and checks the detail payloads. It fails on main and passes with this change. The vendored test-net-perf_hooks.js and test-http-perf_hooks.js still pass.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 10fe3283-398d-4106-b442-174d163a360b

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 114ec8f.

📒 Files selected for processing (3)
  • src/js/internal/perf_hooks/performance_entry.ts
  • src/js/internal/shared.ts
  • test/js/node/perf_hooks/perf_hooks.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:15 PM PT - Jul 6th, 2026

❌ @robobun, your commit 114ec8f has some failures in Build #69146 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33471

That installs a local version of the PR into your bun-33471 executable, so you can run:

bun-33471 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. perf_hooks: PerformanceNodeTiming startTime/duration throw; nodeTiming shape differs from Node #23041 - PR makes observed net/http entries real PerformanceEntry instances, fixing the TypeError thrown when accessing prototype-gated getters like startTime/duration on plain-object entries

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #23041

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

This PR does not fix #23041, so I'm leaving Fixes #23041 out of the description.

#23041 is about performance.nodeTiming, which this PR does not touch. Against this branch:

$ bun-debug -e 'const { performance: p } = require("node:perf_hooks");
const nt = p.nodeTiming;
console.log("nodeStart:", nt.nodeStart, "timeOrigin:", p.timeOrigin);
try { console.log("startTime:", nt.startTime); } catch (e) { console.log("startTime THROWS:", e.message); }
console.log("own props:", Object.getOwnPropertyNames(nt));'

nodeStart: 1783338206609.043 timeOrigin: 1783338206609.043
startTime THROWS: The PerformanceEntry.startTime getter can only be used on instances of PerformanceEntry
own props: [ "v8Start", "nodeStart", "environment", "bootstrapComplete", "idleTime", "loopStart", "loopExit" ]

The two bugs look alike (PerformanceEntry accessors throwing) but have different causes. Here it is stopPerf() building observer entries as object literals. In #23041 it is $toClass replacing PerformanceNodeTiming's prototype with an empty one, dropping the class body's getters so they fall through to the native PerformanceEntry accessors. #32163 fixes that $toClass behavior, though #23041 also asks for nodeStart to be an offset from timeOrigin rather than an absolute epoch timestamp, which is still unaddressed.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues — the approach mirrors Node's lib/internal/perf/performance_entry.js and the test is solid — but since this adds a new internal module and wires up a prototype chain against the native PerformanceEntry, it's worth a quick human look.

Extended reasoning...

Overview

This PR fixes Node.js compatibility for PerformanceObserver entries of type net and http. Previously stopPerf() in src/js/internal/shared.ts emitted plain object literals, so entry instanceof PerformanceEntry was false and entry.toJSON() threw. The fix adds a new internal module src/js/internal/perf_hooks/performance_entry.ts (~86 lines) defining a PerformanceNodeEntry class whose prototype chain is wired to the native PerformanceEntry via Object.setPrototypeOf, mirroring Node's own lib/internal/perf/performance_entry.js. stopPerf() now lazy-requires and constructs these instead of literals. A ~65-line test in test/js/node/perf_hooks/perf_hooks.test.ts exercises the full flow with a real HTTP server/client and asserts instanceof, toJSON(), JSON.stringify round-tripping, and detail payloads.

Security risks

None. This is pure JS shaping of already-emitted metrics objects; no new inputs are parsed, no filesystem/network/auth surface is touched. The lazy require follows the existing pattern used for monitorEventLoopDelay in the same directory.

Level of scrutiny

Medium. It's a focused, well-tested Node-compat fix with clear precedent in the file next door ($toClass(PerformanceNodeTiming, ..., PerformanceEntry) in perf_hooks.ts), and the PR description explains why setPrototypeOf is used instead of extends (native PerformanceEntry has no callable constructor). That said, it introduces a new internal module and hand-wires a prototype chain against a native WebCore class, which changes user-observable instanceof/constructor.name/enumerability semantics — the kind of Node/Web compat surface that CLAUDE.md flags for a human sanity check.

Other factors

  • No CODEOWNERS match for the touched paths.
  • The bug-hunting system found no issues.
  • The lazy-require + export default { ... } pattern matches the sibling internal/perf_hooks/monitorEventLoopDelay module, so the module registry/bundler should pick it up without additional registration.
  • The test uses Promise.withResolvers, wires error handlers to reject, and cleans up the observer/server in finally — it follows the harness conventions well.
  • No prior human or bot reviews to consider; CI was still building at time of review.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Good call on the instanceof / constructor.name / enumerability surface being the part worth checking. I diffed the observable shape of an observed net entry against Node v26.3.0, and it turned up one thing I had wrong, now fixed in 1a09403.

Node v26.3.0 Bun (this PR)
entry instanceof PerformanceEntry true true
entry.constructor.name PerformanceNodeEntry PerformanceNodeEntry
Object.getOwnPropertyNames(entry) [] []
2nd prototype === PerformanceEntry.prototype true true
for...in keys name, entryType, startTime, duration name, entryType, startTime, duration
typeof entry.toJSON function function
Object.keys(entry.toJSON()) name, entryType, startTime, duration, detail name, entryType, startTime, duration, detail
JSON.stringify(entry) keys same five same five
Object.prototype.toString.call(entry) [object Object] [object PerformanceEntry]

The fix: I had marked detail and toJSON enumerable on PerformanceNodeEntry.prototype. Node leaves both non-enumerable and only marks the four base accessors enumerable, so for...in was yielding six keys instead of four. Corrected, and the test now asserts both Object.keys(entry) and the for...in key set so it can't drift back.

The one remaining divergence is Symbol.toStringTag, and I left it alone deliberately. Bun's native PerformanceEntry.prototype carries a Symbol.toStringTag; Node's JS one does not. Chaining to the native prototype means entries inherit [object PerformanceEntry] where Node reports [object Object]. Shadowing it back to undefined would make these entries the only PerformanceEntry in Bun without a tag (performance.mark() already reports [object PerformanceMark]), so consistency with Bun's own PerformanceEntry family seemed better than matching that Node quirk. Happy to flip it if a maintainer disagrees.

On the prototype chain, the one structural difference is unavoidable: Node splits name/entryType/startTime/duration onto its JS PerformanceEntry.prototype, while this has to shadow them on PerformanceNodeEntry.prototype because Bun's native accessors throw on a non-native this. The properties resolve identically; only a prototype walk can see the difference.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused Node compat fix that swaps the object-literal entries for a proper PerformanceNodeEntry mirroring Node's own internal/perf/performance_entry.js, with solid test coverage.

Extended reasoning...

Overview

This PR fixes PerformanceObserver entries for the Node-only net/http/dns entry types. Previously stopPerf() in src/js/internal/shared.ts built each entry as a plain object literal, so entry instanceof PerformanceEntry was false and entry.toJSON() threw. The fix adds src/js/internal/perf_hooks/performance_entry.ts (a new ~85-line internal module) defining a PerformanceNodeEntry class with symbol-backed storage, prototype accessors, toJSON(), and a util.inspect.custom hook, then wires its prototype chain to the native PerformanceEntry via setPrototypeOf (since the native constructor is not callable). stopPerf() now constructs one of these instead of an object literal — a mechanical five-argument swap. A new test in test/js/node/perf_hooks/perf_hooks.test.ts exercises a real net + http observation and asserts the full observable shape (instanceof, prototype chain, toJSON(), JSON.stringify round-trip, own-keys empty, four enumerable prototype keys, and the detail payloads).

Security risks

None. This is pure-JS Node.js compatibility code that shapes diagnostic/telemetry objects handed to user PerformanceObserver callbacks. No auth, crypto, filesystem, network parsing, or untrusted-input validation is touched. The __proto__: null on the descriptor object and symbol-keyed storage are defensive but not security-critical here.

Level of scrutiny

Low-to-moderate. It's a self-contained Node compat fix in built-in JS with no native code changes. The implementation is a close port of Node's own lib/internal/perf/performance_entry.js (symbol slots, enumerable base accessors, non-enumerable detail/toJSON, hand-wired prototype chain), and the new file lives alongside the existing internal/perf_hooks/monitorEventLoopDelay.ts following the same lazy-require pattern already used in this codebase. The shared.ts change is a mechanical object-literal → constructor-call swap with identical argument semantics.

Other factors

  • The new test is well-constructed: port: 0, error events wired to reject, awaits the actual observer callback rather than sleeping, and cleans up via finally. It asserts strong invariants (exact key sets, prototype identity, JSON round-trip) rather than loose shape checks.
  • No CODEOWNERS entries cover the touched paths.
  • The bug-hunting system found no issues.
  • The PR description explicitly verified the vendored test-net-perf_hooks.js and test-http-perf_hooks.js still pass, and the author correctly declined to auto-close #23041 (different root cause).

The 'net' and 'http' entries handed to a PerformanceObserver were plain
object literals, so entry.toJSON() threw and entry instanceof
PerformanceEntry was false. Build them as PerformanceNodeEntry, whose
prototype chains to the native PerformanceEntry, matching Node.
@robobun
robobun force-pushed the farm/4439bc82/perf-hooks-node-entries branch from 1a09403 to 81a8ad8 Compare July 6, 2026 13:02

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found — the implementation looks correct and well-tested, but this adds a new internal module with prototype-chain wiring against the native PerformanceEntry, and you've flagged an open Symbol.toStringTag tradeoff for a maintainer to weigh in on, so worth a human look.

Extended reasoning...

Overview

This PR fixes Node.js compat for perf_hooks observer entries: the net/http/dns entries dispatched via the JS-side stopPerf() registry in internal/shared.ts were plain object literals, so entry instanceof PerformanceEntry was false and entry.toJSON() threw. The fix adds a new ~86-line internal module src/js/internal/perf_hooks/performance_entry.ts defining PerformanceNodeEntry (symbol-keyed storage, accessors, toJSON(), inspect.custom, prototype chained to the native PerformanceEntry via setPrototypeOf), swaps the object literal in stopPerf() for a lazy-required constructor call, and adds a ~75-line test asserting the full observable shape (instanceof, prototype chain, own keys, for...in enumerability, toJSON()/JSON.stringify round-trip, detail payloads).

Security risks

None. This is pure JS-side Node compat surface — no native code, no untrusted input parsing, no auth/crypto/permissions. The new module only reads globalThis.PerformanceEntry at load time and lazy-requires node:util for inspect.

Level of scrutiny

Medium. It's a well-scoped compat fix that closely mirrors Node's own lib/internal/perf/performance_entry.js, with thorough verification against Node v26.3.0 documented in the thread. But it's ~170 lines of new code touching prototype-chain wiring against a native WebCore class, and Node-compat shape details (enumerability, own vs. prototype accessors, toStringTag) are exactly the kind of thing that benefits from a second pair of eyes.

Other factors

  • The author explicitly flags one deliberate divergence from Node (Object.prototype.toString.call(entry) reports [object PerformanceEntry] vs. Node's [object Object]) and invites a maintainer to flip it if they disagree — that's a small design call someone should ack.
  • The lazy-require pattern and module placement (src/js/internal/perf_hooks/, alongside the existing monitorEventLoopDelay.ts) follow existing conventions; no manual registry update appears needed.
  • Test coverage is strong: wires error events to reject, uses port: 0, cleans up in finally, and asserts both Object.keys(entry) and the for...in key set to lock in the enumerability fix from the follow-up commit.
  • CI build #68983 was still in progress at review time.

@robobun
robobun force-pushed the farm/4439bc82/perf-hooks-node-entries branch from 81a8ad8 to dfa60d1 Compare July 6, 2026 13:27

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused Node-compat fix that swaps the plain-object entries for a proper PerformanceNodeEntry chained to the native prototype, with a solid test locking down the observable shape.

Extended reasoning...

Overview

This PR touches three files: it adds src/js/internal/perf_hooks/performance_entry.ts (a new ~85-line internal module defining PerformanceNodeEntry), makes a mechanical change in src/js/internal/shared.ts to construct one of those instead of an object literal in stopPerf(), and extends test/js/node/perf_hooks/perf_hooks.test.ts with a test that observes net/http entries and asserts instanceof PerformanceEntry, prototype chain, enumerability, toJSON(), and detail payloads. The new module mirrors Node's lib/internal/perf/performance_entry.js: symbol-backed storage behind accessors, toJSON(), a util.inspect.custom, and a hand-wired prototype chain via setPrototypeOf (since the native PerformanceEntry constructor is not callable).

Security risks

None. This is pure built-in JS in the Node-compat layer — no auth, crypto, filesystem, or untrusted-input parsing. The only external interaction is the test's loopback HTTP request on port: 0.

Level of scrutiny

Low-to-moderate. The shared.ts edit is a straight refactor: the same five values that populated the object literal are now passed positionally to createPerformanceNodeEntry in the same order the constructor consumes them. The new class is small and closely tracks upstream Node; the prototype-wiring approach matches the existing internal/perf_hooks/monitorEventLoopDelay.ts sibling's placement and the lazy-require idiom used throughout src/js. No CODEOWNER paths are touched.

Other factors

The author already ran a property-by-property diff against Node v26.3.0 (posted in the thread), caught and fixed an enumerability mismatch in a follow-up commit, and documented the one intentional remaining divergence (Symbol.toStringTag inherited from the native prototype) with a reasonable rationale. The test is well-constructed per repo conventions — awaits real conditions, wires error events to reject, cleans up in finally, and asserts exact key sets so the shape can't silently drift. The bug-hunting pass found nothing. This is a clean, self-contained compatibility improvement.

detail and toJSON are non-enumerable in Node, so a for...in over an entry
yields name/entryType/startTime/duration and nothing else.
@robobun
robobun force-pushed the farm/4439bc82/perf-hooks-node-entries branch from dfa60d1 to 114ec8f Compare July 6, 2026 16:45

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean Node-compat fix that mirrors Node's own PerformanceNodeEntry, with a thorough test.

Extended reasoning...

Overview

This PR fixes a Node.js compatibility gap: net/http entries delivered to a PerformanceObserver were plain object literals rather than real PerformanceEntry instances, so entry instanceof PerformanceEntry was false and entry.toJSON() threw. It adds a new internal module src/js/internal/perf_hooks/performance_entry.ts defining PerformanceNodeEntry (symbol-backed fields, accessor getters, toJSON(), custom inspect, prototype chain wired to the native PerformanceEntry via setPrototypeOf), swaps the object literal in stopPerf() (src/js/internal/shared.ts) for a lazily-required factory call, and adds a comprehensive test in test/js/node/perf_hooks/perf_hooks.test.ts.

Security risks

None. This only changes the prototype/shape of performance-timing entry objects handed to user observers. No parsing of untrusted input, no auth, no filesystem/network surface beyond the existing test's local loopback server.

Level of scrutiny

Low-to-moderate. This is a self-contained Node-compat shim in built-in JS that only executes when a PerformanceObserver is registered for net/http/dns (the new module is lazy-required inside stopPerf(), so there's no startup cost). The implementation is a close transcription of Node's lib/internal/perf/performance_entry.js — the PR description cites the upstream source and explains why extends PerformanceEntry can't be used (native constructor throws) and why setPrototypeOf is used instead, which is the same technique Node uses. No CODEOWNERS cover these paths.

Other factors

  • The bug-hunting system found no issues.
  • The new test is hermetic (port: 0, loopback, finally cleanup, awaits the observer callback rather than sleeping) and asserts the full Node-observable contract: instanceof, constructor.name, prototype parent, toJSON() shape, JSON.stringify round-trip, own-keys empty, for...in enumerating exactly the four base accessors, plus per-entry-type detail payloads.
  • Enumerability handling (four base accessors enumerable, detail/toJSON not) matches Node and is verified by the test.
  • The shared.ts change is a minimal mechanical rewrite of the existing object literal into a factory call with identical arguments; the detail merge logic is unchanged.
  • No outstanding reviewer comments and no prior reviews on the timeline.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

One more verification worth recording, since a new built-in JS module can behave differently once src/js is bundled for production: this is green on a release build, not just debug.

$ bun scripts/build.ts --profile=release
$ ./build/release/bun test test/js/node/perf_hooks/perf_hooks.test.ts
(pass) observed net and http entries are PerformanceEntry objects with toJSON()
 3 pass
 0 fail
 48 expect() calls

That matters for one assertion in particular. The test checks entry.constructor.name === "PerformanceNodeEntry", which would break if the production bundle renamed identifiers. It doesn't: src/js is whitespace-minified but keeps names, same as the existing PerformanceNodeTiming in node/perf_hooks.ts. So the assertion is a real part of the Node contract here, not an accident of the debug build.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green; two lanes are failing on infrastructure

This is ready for review. The red X comes from two darwin: 26 aarch64 - test-bun jobs that never execute a single test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).
🚨 Error: The command exited with status 1

That is the agent failing to fetch the compiled binary, before any test code runs. The artifact it times out on, darwin: aarch64 - build-bun, built successfully from this very diff.

Everything else passes:

test lanes 224 passed / 2 failed (both the artifact timeout above)
test/js/node/perf_hooks/perf_hooks.test.ts passes on debian 13 x64-asan
release builds build-cpp + build-rust + build-bun green on all 9 platforms

It reproduced identically on two independent builds (#69012, #69146), same lane, same 120s timeout, so it is not transient flake I can shake off by re-running. A three-file change to built-in JS has no way to affect whether a macOS agent can download a 70 MB artifact in time, so I have stopped re-triggering rather than burn CI capacity on it.

Happy to rebase or re-run if someone with access to the darwin 26 aarch64 agents wants to look, or if a maintainer prefers to merge on the strength of the green lanes.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-06, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant