Compare commits

...

1 Commits

Author SHA1 Message Date
Jarred Sumner
f42401a4f2 Introduce Bun.fetch.stats 2025-05-19 01:01:07 -07:00
9 changed files with 513 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -41,7 +41,7 @@
#include "BunObjectModule.h"
#include "JSCookie.h"
#include "JSCookieMap.h"
#include "JSHTTPStats.h"
#ifdef WIN32
#include <ws2def.h>
#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<Zig::GlobalObject*>(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;
}

View File

@@ -0,0 +1,120 @@
#include "root.h"
#include <JavaScriptCore/JSObject.h>
#include <JavaScriptCore/JSString.h>
#include <JavaScriptCore/JSFunction.h>
#include <JavaScriptCore/JSGlobalObject.h>
#include <JavaScriptCore/JSObject.h>
#include <JavaScriptCore/ObjectConstructor.h>
namespace Bun {
using namespace JSC;
struct Bun__HTTPStats {
std::atomic<uint64_t> total_requests;
std::atomic<uint64_t> total_bytes_sent;
std::atomic<uint64_t> total_bytes_received;
std::atomic<uint64_t> total_requests_failed;
std::atomic<uint64_t> total_requests_redirected;
std::atomic<uint64_t> total_requests_succeeded;
std::atomic<uint64_t> total_requests_timed_out;
std::atomic<uint64_t> total_requests_connection_refused;
};
extern "C" Bun__HTTPStats Bun__HTTPStats;
static_assert(std::atomic<uint64_t>::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<uint64_t> 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<typename CellType, SubspaceAccess>
static GCClient::IsoSubspace* subspaceFor(VM& vm)
{
return &vm.plainObjectSpace();
}
static JSHTTPStatsObject* create(VM& vm, Structure* structure)
{
JSHTTPStatsObject* object = new (NotNull, allocateCell<JSHTTPStatsObject>(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()));
}
}

View File

@@ -0,0 +1,4 @@
namespace Bun {
JSC::JSObject* constructBunHTTPStatsObject(JSC::JSGlobalObject* globalObject);
}

View File

@@ -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 <d>http stats | <r> ", enable_ansi_colors));
needs_space = false;
if (active_requests > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
try writer.print(
Output.prettyFmt("active<d>:<r> <blue><b>{}<r>", enable_ansi_colors),
.{active_requests},
);
needs_space = true;
}
if (total_requests_succeeded > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
needs_space = true;
try writer.print(
Output.prettyFmt("ok<d>:<r> <green><b>{}<r>", enable_ansi_colors),
.{total_requests_succeeded},
);
}
if (total_requests_failed > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
try writer.print(
Output.prettyFmt("fail<d>:<r> <red><b>{}<r>", enable_ansi_colors),
.{total_requests_failed},
);
needs_space = true;
}
if (total_bytes_received > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
try writer.print(
Output.prettyFmt("recv<d>:<r> <b>{}<r>", 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(" <d>|<r> ", enable_ansi_colors));
try writer.print(
Output.prettyFmt("sent<d>:<r> <b>{}<r>", 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(" <d>|<r> ", enable_ansi_colors));
try writer.print(
Output.prettyFmt("redirect<d>:<r> <yellow>{}<r>", enable_ansi_colors),
.{total_requests_redirected},
);
needs_space = true;
}
if (total_requests_timed_out > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
needs_space = true;
try writer.print(
Output.prettyFmt("timeout<d>:<r> <b>{}<r>", enable_ansi_colors),
.{total_requests_timed_out},
);
needs_space = true;
}
if (total_requests_connection_refused > 0) {
if (needs_space) try writer.writeAll(Output.prettyFmt(" <d>|<r> ", enable_ansi_colors));
needs_space = true;
try writer.print(
Output.prettyFmt("refused<d>:<r> <red>{}<r>", 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" });
}

View File

@@ -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");
}
});
});