From f42401a4f22192e1af025fa61bc6a9a93697871f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 May 2025 01:01:07 -0700 Subject: [PATCH] Introduce Bun.fetch.stats --- cmake/CxxSources.txt | 1 + cmake/ZigSources.txt | 1 + cmake/targets/BuildBun.cmake | 2 + packages/bun-types/globals.d.ts | 58 +++++++ src/bun.js/bindings/BunObject.cpp | 8 +- src/bun.js/bindings/JSHTTPStats.cpp | 120 +++++++++++++ src/bun.js/bindings/JSHTTPStats.h | 4 + src/http.zig | 232 +++++++++++++++++++++++--- test/js/web/fetch/fetch-stats.test.ts | 109 ++++++++++++ 9 files changed, 513 insertions(+), 22 deletions(-) create mode 100644 src/bun.js/bindings/JSHTTPStats.cpp create mode 100644 src/bun.js/bindings/JSHTTPStats.h create mode 100644 test/js/web/fetch/fetch-stats.test.ts diff --git a/cmake/CxxSources.txt b/cmake/CxxSources.txt index 18697f349c..44a26bc1bc 100644 --- a/cmake/CxxSources.txt +++ b/cmake/CxxSources.txt @@ -78,6 +78,7 @@ src/bun.js/bindings/JSDOMWrapper.cpp src/bun.js/bindings/JSDOMWrapperCache.cpp src/bun.js/bindings/JSEnvironmentVariableMap.cpp src/bun.js/bindings/JSFFIFunction.cpp +src/bun.js/bindings/JSHTTPStats.cpp src/bun.js/bindings/JSMockFunction.cpp src/bun.js/bindings/JSNextTickQueue.cpp src/bun.js/bindings/JSPropertyIterator.cpp diff --git a/cmake/ZigSources.txt b/cmake/ZigSources.txt index 1dcd73c2a5..0aff5f740f 100644 --- a/cmake/ZigSources.txt +++ b/cmake/ZigSources.txt @@ -222,6 +222,7 @@ src/bun.js/webcore/Response.zig src/bun.js/webcore/S3Client.zig src/bun.js/webcore/S3File.zig src/bun.js/webcore/S3Stat.zig +src/bun.js/webcore/ScriptExecutionContext.zig src/bun.js/webcore/Sink.zig src/bun.js/webcore/streams.zig src/bun.js/webcore/TextDecoder.zig diff --git a/cmake/targets/BuildBun.cmake b/cmake/targets/BuildBun.cmake index c9600843e7..068a704cdf 100644 --- a/cmake/targets/BuildBun.cmake +++ b/cmake/targets/BuildBun.cmake @@ -415,6 +415,7 @@ set(BUN_OBJECT_LUT_SOURCES ${CWD}/src/bun.js/bindings/ProcessBindingNatives.cpp ${CWD}/src/bun.js/modules/NodeModuleModule.cpp ${CODEGEN_PATH}/ZigGeneratedClasses.lut.txt + ${CWD}/src/bun.js/bindings/JSHTTPStats.cpp ) set(BUN_OBJECT_LUT_OUTPUTS @@ -428,6 +429,7 @@ set(BUN_OBJECT_LUT_OUTPUTS ${CODEGEN_PATH}/ProcessBindingNatives.lut.h ${CODEGEN_PATH}/NodeModuleModule.lut.h ${CODEGEN_PATH}/ZigGeneratedClasses.lut.h + ${CODEGEN_PATH}/JSHTTPStats.lut.h ) macro(WEBKIT_ADD_SOURCE_DEPENDENCIES _source _deps) diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index db253bdefc..6ec2657b08 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1897,5 +1897,63 @@ declare namespace fetch { https?: boolean; }, ): void; + + /** + * Statistics about fetch() & node:http client requests. + * + * @example + * ```js + * console.log(fetch.stats); + * // { + * // requests: 10, + * // bytesWritten: 1000, + * // bytesRead: 500, + * // fail: 1, + * // redirect: 2, + * // success: 7, + * // timeout: 0, + * // refused: 0, + * // active: 0, + * // } + * ``` + */ + export const stats: { + /** + * Total number of HTTP requests initiated since the process started + */ + readonly requests: number; + /** + * Total number of bytes written in HTTP requests across the process (including Worker threads) + */ + readonly bytesWritten: number; + /** + * Total number of bytes read from fetch responses across the process (including Worker threads) + */ + readonly bytesRead: number; + /** + * Number of HTTP requests that failed for any reason across the process (including Worker threads) + */ + readonly fail: number; + /** + * Number of HTTP requests that were redirected across the process (including Worker threads) + */ + readonly redirect: number; + /** + * Number of HTTP requests that succeeded across the process (including Worker threads) + */ + readonly success: number; + /** + * Number of HTTP requests that timed out across the process (including Worker threads) + */ + readonly timeout: number; + /** + * Number of HTTP requests that were refused by the server across the process (including Worker threads) + */ + readonly refused: number; + /** + * Number of HTTP requests currently in progress across the process (including Worker threads) + */ + readonly active: number; + }; } //#endregion diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index af9a78fcfd..5c90b15f76 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -41,7 +41,7 @@ #include "BunObjectModule.h" #include "JSCookie.h" #include "JSCookieMap.h" - +#include "JSHTTPStats.h" #ifdef WIN32 #include #else @@ -328,12 +328,14 @@ static JSValue constructPasswordObject(VM& vm, JSObject* bunObject) JSValue constructBunFetchObject(VM& vm, JSObject* bunObject) { - JSFunction* fetchFn = JSFunction::create(vm, bunObject->globalObject(), 1, "fetch"_s, Bun__fetch, ImplementationVisibility::Public, NoIntrinsic); + auto* globalObject = defaultGlobalObject(bunObject->globalObject()); + JSFunction* fetchFn = JSFunction::create(vm, globalObject, 1, "fetch"_s, Bun__fetch, ImplementationVisibility::Public, NoIntrinsic); - auto* globalObject = jsCast(bunObject->globalObject()); fetchFn->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, "preconnect"_s), 1, Bun__fetchPreconnect, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete | 0); + fetchFn->putDirect(vm, JSC::Identifier::fromString(vm, "stats"_s), Bun::constructBunHTTPStatsObject(bunObject->globalObject()), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete | 0); + return fetchFn; } diff --git a/src/bun.js/bindings/JSHTTPStats.cpp b/src/bun.js/bindings/JSHTTPStats.cpp new file mode 100644 index 0000000000..cc40ac34a5 --- /dev/null +++ b/src/bun.js/bindings/JSHTTPStats.cpp @@ -0,0 +1,120 @@ +#include "root.h" +#include +#include +#include +#include +#include +#include + +namespace Bun { + +using namespace JSC; + +struct Bun__HTTPStats { + std::atomic total_requests; + std::atomic total_bytes_sent; + std::atomic total_bytes_received; + std::atomic total_requests_failed; + std::atomic total_requests_redirected; + std::atomic total_requests_succeeded; + std::atomic total_requests_timed_out; + std::atomic total_requests_connection_refused; +}; +extern "C" Bun__HTTPStats Bun__HTTPStats; +static_assert(std::atomic::is_always_lock_free, "Bun__HTTPStats must be lock-free"); + +// clang-format off +#define STATS_GETTER(name) \ + JSC_DEFINE_CUSTOM_GETTER(getStatsField_##name, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) \ + { \ + return JSValue::encode(jsNumber(Bun__HTTPStats.name)); \ + } \ + \ + +#define FOR_EACH_STATS_FIELD(macro) \ + macro(total_requests) \ + macro(total_bytes_sent) \ + macro(total_bytes_received) \ + macro(total_requests_failed) \ + macro(total_requests_redirected) \ + macro(total_requests_succeeded) \ + macro(total_requests_timed_out) \ + macro(total_requests_connection_refused) + +// clang-format on + +FOR_EACH_STATS_FIELD(STATS_GETTER) + +#undef STATS_GETTER +#undef FOR_EACH_STATS_FIELD + +extern "C" std::atomic Bun__HTTPStats__total_requests_active; + +JSC_DEFINE_CUSTOM_GETTER(getStatsField_total_requests_active, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + return JSValue::encode(jsNumber(Bun__HTTPStats__total_requests_active)); +} + +class JSHTTPStatsObject final : public JSNonFinalObject { +public: + using Base = JSNonFinalObject; + + static constexpr unsigned StructureFlags = Base::StructureFlags | HasStaticPropertyTable; + + template + static GCClient::IsoSubspace* subspaceFor(VM& vm) + { + return &vm.plainObjectSpace(); + } + + static JSHTTPStatsObject* create(VM& vm, Structure* structure) + { + JSHTTPStatsObject* object = new (NotNull, allocateCell(vm)) JSHTTPStatsObject(vm, structure); + object->finishCreation(vm); + return object; + } + + DECLARE_INFO; + + static Structure* createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) + { + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); + } + +private: + JSHTTPStatsObject(VM& vm, Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(VM& vm) + { + Base::finishCreation(vm); + } +}; + +/* Source for JSHTTPStats.lut.h +@begin jsHTTPStatsObjectTable + requests getStatsField_total_requests CustomAccessor|ReadOnly|DontDelete + active getStatsField_total_requests_active CustomAccessor|ReadOnly|DontDelete + success getStatsField_total_requests_succeeded CustomAccessor|ReadOnly|DontDelete + bytesWritten getStatsField_total_bytes_sent CustomAccessor|ReadOnly|DontDelete + bytesRead getStatsField_total_bytes_received CustomAccessor|ReadOnly|DontDelete + fail getStatsField_total_requests_failed CustomAccessor|ReadOnly|DontDelete + redirect getStatsField_total_requests_redirected CustomAccessor|ReadOnly|DontDelete + timeout getStatsField_total_requests_timed_out CustomAccessor|ReadOnly|DontDelete + refused getStatsField_total_requests_connection_refused CustomAccessor|ReadOnly|DontDelete +@end +*/ +#include "JSHTTPStats.lut.h" + +const ClassInfo JSHTTPStatsObject::s_info = { "HTTPStats"_s, &Base::s_info, &jsHTTPStatsObjectTable, nullptr, CREATE_METHOD_TABLE(JSHTTPStatsObject) }; + +JSC::JSObject* constructBunHTTPStatsObject(JSC::JSGlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + + return JSHTTPStatsObject::create(vm, JSHTTPStatsObject::createStructure(vm, globalObject, globalObject->objectPrototype())); +} + +} diff --git a/src/bun.js/bindings/JSHTTPStats.h b/src/bun.js/bindings/JSHTTPStats.h new file mode 100644 index 0000000000..b1b3f09442 --- /dev/null +++ b/src/bun.js/bindings/JSHTTPStats.h @@ -0,0 +1,4 @@ + +namespace Bun { +JSC::JSObject* constructBunHTTPStatsObject(JSC::JSGlobalObject* globalObject); +} diff --git a/src/http.zig b/src/http.zig index 8401a55cdc..736e7cfae7 100644 --- a/src/http.zig +++ b/src/http.zig @@ -116,6 +116,170 @@ pub const FetchRedirect = enum(u8) { }); }; +pub const Stats = extern struct { + total_requests: std.atomic.Value(u64) = .init(0), + total_bytes_sent: std.atomic.Value(u64) = .init(0), + total_bytes_received: std.atomic.Value(u64) = .init(0), + total_requests_failed: std.atomic.Value(u64) = .init(0), + total_requests_redirected: std.atomic.Value(u64) = .init(0), + total_requests_succeeded: std.atomic.Value(u64) = .init(0), + total_requests_timed_out: std.atomic.Value(u64) = .init(0), + total_requests_connection_refused: std.atomic.Value(u64) = .init(0), + + pub var instance: Stats = .{}; + + pub fn addRequest() void { + _ = instance.total_requests.fetchAdd(1, .monotonic); + } + + pub fn addBytesSent(bytes: u64) void { + _ = instance.total_bytes_sent.fetchAdd(bytes, .monotonic); + } + + pub fn addBytesReceived(bytes: u64) void { + _ = instance.total_bytes_received.fetchAdd(bytes, .monotonic); + } + + pub fn addRequestsFailed() void { + _ = instance.total_requests_failed.fetchAdd(1, .monotonic); + } + + pub fn addRequestsRedirected() void { + _ = instance.total_requests_redirected.fetchAdd(1, .monotonic); + } + + pub fn addRequestsSucceeded() void { + _ = instance.total_requests_succeeded.fetchAdd(1, .monotonic); + } + + pub fn addRequestsTimedOut() void { + _ = instance.total_requests_timed_out.fetchAdd(1, .monotonic); + } + + pub fn addRequestsConnectionRefused() void { + _ = instance.total_requests_connection_refused.fetchAdd(1, .monotonic); + } + + pub fn fmt() Formatter { + return .{ + .enable_color = bun.Output.enable_ansi_colors_stderr, + }; + } + + pub const Formatter = struct { + enable_color: bool = false, + + pub fn format(this: Formatter, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + const total_requests = Stats.instance.total_requests.load(.monotonic); + const total_bytes_sent = Stats.instance.total_bytes_sent.load(.monotonic); + const total_bytes_received = Stats.instance.total_bytes_received.load(.monotonic); + const total_requests_failed = Stats.instance.total_requests_failed.load(.monotonic); + const total_requests_redirected = Stats.instance.total_requests_redirected.load(.monotonic); + const total_requests_succeeded = Stats.instance.total_requests_succeeded.load(.monotonic); + const total_requests_timed_out = Stats.instance.total_requests_timed_out.load(.monotonic); + const total_requests_connection_refused = Stats.instance.total_requests_connection_refused.load(.monotonic); + const active_requests = AsyncHTTP.active_requests_count.load(.monotonic); + var needs_space = false; + + if (!(total_bytes_received > 0 or + total_bytes_sent > 0 or + total_requests > 0 or + active_requests > 0 or + total_requests_failed > 0 or + total_requests_redirected > 0 or + total_requests_succeeded > 0 or + total_requests_timed_out > 0 or + total_requests_connection_refused > 0)) + { + return; + } + + switch (this.enable_color) { + inline else => |enable_ansi_colors| { + try writer.writeAll(Output.prettyFmt("\n http stats | ", enable_ansi_colors)); + needs_space = false; + + if (active_requests > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + try writer.print( + Output.prettyFmt("active: {}", enable_ansi_colors), + .{active_requests}, + ); + needs_space = true; + } + + if (total_requests_succeeded > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + needs_space = true; + try writer.print( + Output.prettyFmt("ok: {}", enable_ansi_colors), + .{total_requests_succeeded}, + ); + } + + if (total_requests_failed > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + try writer.print( + Output.prettyFmt("fail: {}", enable_ansi_colors), + .{total_requests_failed}, + ); + needs_space = true; + } + + if (total_bytes_received > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + try writer.print( + Output.prettyFmt("recv: {}", enable_ansi_colors), + .{bun.fmt.size(total_bytes_received, .{})}, + ); + needs_space = true; + } + + if (total_bytes_sent > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + try writer.print( + Output.prettyFmt("sent: {}", enable_ansi_colors), + .{bun.fmt.size(total_bytes_sent, .{})}, + ); + needs_space = true; + } + + if (total_requests_redirected > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + try writer.print( + Output.prettyFmt("redirect: {}", enable_ansi_colors), + .{total_requests_redirected}, + ); + needs_space = true; + } + + if (total_requests_timed_out > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + needs_space = true; + try writer.print( + Output.prettyFmt("timeout: {}", enable_ansi_colors), + .{total_requests_timed_out}, + ); + needs_space = true; + } + + if (total_requests_connection_refused > 0) { + if (needs_space) try writer.writeAll(Output.prettyFmt(" | ", enable_ansi_colors)); + needs_space = true; + try writer.print( + Output.prettyFmt("refused: {}", enable_ansi_colors), + .{total_requests_connection_refused}, + ); + needs_space = true; + } + + try writer.writeAll("\n"); + }, + } + } + }; +}; + pub const HTTPRequestBody = union(enum) { bytes: []const u8, sendfile: Sendfile, @@ -411,7 +575,9 @@ const ProxyTunnel = struct { .tcp => |socket| socket.write(encoded_data, true), .none => 0, }; - const pending = encoded_data[@intCast(written)..]; + const written_bytes: usize = @intCast(@max(written, 0)); + Stats.addBytesSent(written_bytes); + const pending = encoded_data[written_bytes..]; if (pending.len > 0) { // lets flush when we are truly writable proxy.write_buffer.write(pending) catch bun.outOfMemory(); @@ -489,6 +655,8 @@ const ProxyTunnel = struct { return; } const written = socket.write(encoded_data, true); + const written_bytes: usize = @intCast(@max(written, 0)); + Stats.addBytesSent(written_bytes); if (written == encoded_data.len) { this.write_buffer.reset(); return; @@ -1512,6 +1680,7 @@ pub const HTTPThread = struct { { var batch_ = batch; + _ = Stats.instance.total_requests.fetchAdd(batch.len, .monotonic); while (batch_.pop()) |task| { const http: *AsyncHTTP = @fieldParentPtr("task", task); this.queued_tasks.push(http); @@ -1746,12 +1915,14 @@ pub fn onTimeout( log("Timeout {s}\n", .{client.url.href}); defer NewHTTPContext(is_ssl).terminateSocket(socket); + Stats.addRequestsTimedOut(); client.fail(error.Timeout); } pub fn onConnectError( client: *HTTPClient, ) void { log("onConnectError {s}\n", .{client.url.href}); + Stats.addRequestsConnectionRefused(); client.fail(error.ConnectionRefused); } @@ -1780,13 +1951,6 @@ pub inline fn cleanup(force: bool) void { default_arena.gc(force); } -pub const SOCKET_FLAGS: u32 = if (Environment.isLinux) - SOCK.CLOEXEC | posix.MSG.NOSIGNAL -else - SOCK.CLOEXEC; - -pub const OPEN_SOCKET_FLAGS = SOCK.CLOEXEC; - pub const extremely_verbose = false; fn writeProxyConnect( @@ -2453,6 +2617,11 @@ pub const AsyncHTTP = struct { pub var active_requests_count = std.atomic.Value(usize).init(0); pub var max_simultaneous_requests = std.atomic.Value(usize).init(256); + comptime { + // This is not part of Stats because it's used in other places + @export(&active_requests_count, .{ .name = "Bun__HTTPStats__total_requests_active" }); + } + pub fn loadEnv(allocator: std.mem.Allocator, logger: *Log, env: *DotEnv.Loader) void { if (env.get("BUN_CONFIG_MAX_HTTP_REQUESTS")) |max_http_requests| { const max = std.fmt.parseInt(u16, max_http_requests, 10) catch { @@ -3272,7 +3441,9 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call: return error.WriteFailed; } - this.state.request_sent_len += @as(usize, @intCast(amount)); + const sent_bytes: usize = @intCast(@max(amount, 0)); + this.state.request_sent_len += sent_bytes; + Stats.addBytesSent(sent_bytes); const has_sent_headers = this.state.request_sent_len >= headers_len; if (has_sent_headers and this.verbose != .none) { @@ -3371,9 +3542,11 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s this.closeAndFail(error.WriteFailed, is_ssl, socket); return; } + const sent_bytes: usize = @intCast(@max(amount, 0)); - this.state.request_sent_len += @as(usize, @intCast(amount)); - this.state.request_body = this.state.request_body[@as(usize, @intCast(amount))..]; + Stats.addBytesSent(sent_bytes); + this.state.request_sent_len += sent_bytes; + this.state.request_body = this.state.request_body[sent_bytes..]; if (this.state.request_body.len == 0) { this.state.request_stage = .done; @@ -3391,8 +3564,10 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s this.closeAndFail(error.WriteFailed, is_ssl, socket); return; } - this.state.request_sent_len += @as(usize, @intCast(amount)); - stream.buffer.cursor += @intCast(amount); + const sent_bytes: usize = @intCast(@max(amount, 0)); + this.state.request_sent_len += sent_bytes; + Stats.addBytesSent(sent_bytes); + stream.buffer.cursor += sent_bytes; if (amount < to_send.len) { stream.has_backpressure = true; } @@ -3436,8 +3611,10 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s const to_send = this.state.request_body; const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose - this.state.request_sent_len += @as(usize, @intCast(amount)); - this.state.request_body = this.state.request_body[@as(usize, @intCast(amount))..]; + const sent_bytes: usize = @intCast(@max(amount, 0)); + this.state.request_sent_len += sent_bytes; + Stats.addBytesSent(sent_bytes); + this.state.request_body = this.state.request_body[sent_bytes..]; if (this.state.request_body.len == 0) { this.state.request_stage = .done; @@ -3452,8 +3629,10 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s if (stream.buffer.isNotEmpty()) { const to_send = stream.buffer.slice(); const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose - this.state.request_sent_len += amount; - stream.buffer.cursor += @truncate(amount); + const sent_bytes: usize = @intCast(@max(amount, 0)); + this.state.request_sent_len += sent_bytes; + Stats.addBytesSent(sent_bytes); + stream.buffer.cursor += sent_bytes; if (amount < to_send.len) { stream.has_backpressure = true; } @@ -3517,7 +3696,9 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s } } - this.state.request_sent_len += @as(usize, @intCast(amount)); + const sent_bytes: usize = @intCast(@max(amount, 0)); + this.state.request_sent_len += sent_bytes; + Stats.addBytesSent(sent_bytes); const has_sent_headers = this.state.request_sent_len >= headers_len; if (has_sent_headers and this.state.request_body.len > 0) { @@ -3676,6 +3857,12 @@ pub fn handleOnDataHeaders( if (this.flags.proxy_tunneling and this.proxy_tunnel == null) { // we are proxing we dont need to cloneMetadata yet this.startProxyHandshake(is_ssl, socket); + + if (body_buf.len > 0) { + if (this.proxy_tunnel) |proxy| { + proxy.receiveData(body_buf); + } + } return; } @@ -3731,6 +3918,7 @@ pub fn onData( socket: NewHTTPContext(is_ssl).HTTPSocket, ) void { log("onData {}", .{incoming_data.len}); + Stats.addBytesReceived(incoming_data.len); if (this.signals.get(.aborted)) { this.closeAndAbort(is_ssl, socket); return; @@ -3823,13 +4011,13 @@ fn fail(this: *HTTPClient, err: anyerror) void { this.state.response_stage = .fail; this.state.fail = err; this.state.stage = .fail; + Stats.addRequestsFailed(); if (!this.flags.defer_fail_until_connecting_is_complete) { const callback = this.result_callback; const result = this.toResult(); this.state.reset(this.allocator); this.flags.proxy_tunneling = false; - callback.run(@fieldParentPtr("client", this), result); } } @@ -3915,6 +4103,7 @@ pub fn progressUpdate(this: *HTTPClient, comptime is_ssl: bool, ctx: *NewHTTPCon this.state.request_stage = .done; this.state.stage = .done; this.flags.proxy_tunneling = false; + Stats.addRequestsSucceeded(); } result.body.?.* = body; @@ -4647,6 +4836,7 @@ pub fn handleResponseMetadata( } } this.state.flags.is_redirect_pending = true; + Stats.addRequestsRedirected(); if (this.method.hasRequestBody()) { this.state.flags.resend_request_body_on_redirect = true; } @@ -4847,3 +5037,7 @@ pub const Headers = struct { return headers; } }; + +comptime { + @export(&Stats.instance, .{ .name = "Bun__HTTPStats" }); +} diff --git a/test/js/web/fetch/fetch-stats.test.ts b/test/js/web/fetch/fetch-stats.test.ts new file mode 100644 index 0000000000..fa2dabf0e2 --- /dev/null +++ b/test/js/web/fetch/fetch-stats.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import "harness"; + +describe("fetch.stats", () => { + it("tracks request statistics", async () => { + // Save initial stats + const initialStats = { + requests: fetch.stats.requests, + bytesWritten: fetch.stats.bytesWritten, + bytesRead: fetch.stats.bytesRead, + success: fetch.stats.success, + active: fetch.stats.active, + fail: fetch.stats.fail, + redirect: fetch.stats.redirect, + timeout: fetch.stats.timeout, + refused: fetch.stats.refused, + }; + + // Start a server + const responseBody = "Hello, World!"; + const requestBody = "Test request body"; + + using server = Bun.serve({ + port: 0, // Use any available port + fetch(req) { + return new Response(responseBody, { + headers: { "Content-Type": "text/plain" }, + }); + }, + }); + + // Make a fetch request with a body + const response = await fetch(server.url, { + method: "POST", + body: requestBody, + }); + + const responseText = await response.text(); + expect(responseText).toBe(responseBody); + + // Verify stats were updated + expect(fetch.stats.requests).toBe(initialStats.requests + 1); + expect(fetch.stats.success).toBe(initialStats.success + 1); + expect(fetch.stats.bytesWritten).toBeGreaterThan(initialStats.bytesWritten); + expect(fetch.stats.bytesRead).toBeGreaterThan(initialStats.bytesRead); + + // Active should return to the same value after request completes + expect(fetch.stats.active).toBe(initialStats.active); + }); + + it("tracks multiple concurrent requests", async () => { + const initialActive = fetch.stats.active; + const initialRequests = fetch.stats.requests; + + // Start a server that delays responses + using server = Bun.serve({ + port: 0, + async fetch(req) { + await Bun.sleep(50); // Small delay to ensure concurrent requests + return new Response("OK"); + }, + }); + + // Start multiple requests without awaiting them + const requests = Array.from({ length: 5 }, () => fetch(server.url).then(r => r.blob())); + + // Check active requests increased + expect(fetch.stats.active).toBeGreaterThan(initialActive); + expect(fetch.stats.requests).toBe(initialRequests + 5); + + // Wait for all requests to complete + await Promise.all(requests); + + // Active should return to initial value + expect(fetch.stats.active).toBe(initialActive); + }); + + it("tracks failed requests", async () => { + const initialFail = fetch.stats.fail; + + // Try to connect to a non-existent server + try { + await fetch("http://localhost:54321"); + } catch (error) { + // Expected to fail + } + + expect(fetch.stats.fail).toBe(initialFail + 1); + }); + + it("has all expected properties", () => { + const expectedProperties = [ + "requests", + "bytesWritten", + "bytesRead", + "fail", + "redirect", + "success", + "timeout", + "refused", + "active", + ] as const; + + for (const prop of expectedProperties) { + expect(fetch.stats).toHaveProperty(prop); + expect(fetch.stats[prop]).toBeTypeOf("number"); + } + }); +});