diff --git a/docs/docs/api/Agent.md b/docs/docs/api/Agent.md index 5141ce3d3a7..2a8e30bac14 100644 --- a/docs/docs/api/Agent.md +++ b/docs/docs/api/Agent.md @@ -75,3 +75,9 @@ See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatche ### `Agent.upgrade(options[, callback])` See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). + +### `Agent.stats()` + +Returns an object of stats by origin in the format of `Record` + +See [`PoolStats`](/docs/docs/api/PoolStats.md) and [`ClientStats`](/docs/docs/api/ClientStats.md). diff --git a/docs/docs/api/ClientStats.md b/docs/docs/api/ClientStats.md new file mode 100644 index 00000000000..fa899d482c8 --- /dev/null +++ b/docs/docs/api/ClientStats.md @@ -0,0 +1,27 @@ +# Class: ClientStats + +Stats for a [Client](/docs/docs/api/Client.md). + +## `new ClientStats(client)` + +Arguments: + +* **client** `Client` - Client from which to return stats. + +## Instance Properties + +### `ClientStats.connected` + +Boolean if socket as open connection by this client. + +### `ClientStats.pending` + +Number of pending requests of this client. + +### `ClientStats.running` + +Number of currently active requests across this client. + +### `ClientStats.size` + +Number of active, pending, or queued requests of this clients. diff --git a/lib/dispatcher/agent.js b/lib/dispatcher/agent.js index 46fc15742d1..938333b63a4 100644 --- a/lib/dispatcher/agent.js +++ b/lib/dispatcher/agent.js @@ -1,7 +1,7 @@ 'use strict' const { InvalidArgumentError } = require('../core/errors') -const { kClients, kRunning, kClose, kDestroy, kDispatch } = require('../core/symbols') +const { kClients, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols') const DispatcherBase = require('./dispatcher-base') const Pool = require('./pool') const Client = require('./client') @@ -110,6 +110,16 @@ class Agent extends DispatcherBase { await Promise.all(destroyPromises) } + + get stats () { + const allClientStats = {} + for (const client of this[kClients].values()) { + if (client.stats) { + allClientStats[client[kUrl].origin] = client.stats + } + } + return allClientStats + } } module.exports = Agent diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index be0e8195d0b..0b0990206e7 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -4,6 +4,7 @@ const assert = require('node:assert') const net = require('node:net') const http = require('node:http') const util = require('../core/util.js') +const { ClientStats } = require('../util/stats.js') const { channels } = require('../core/diagnostics.js') const Request = require('../core/request.js') const DispatcherBase = require('./dispatcher-base') @@ -260,6 +261,10 @@ class Client extends DispatcherBase { this[kResume](true) } + get stats () { + return new ClientStats(this) + } + get [kPending] () { return this[kQueue].length - this[kPendingIdx] } diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js index d0ba2c3c53a..4b7b6a26f1d 100644 --- a/lib/dispatcher/pool-base.js +++ b/lib/dispatcher/pool-base.js @@ -1,9 +1,9 @@ 'use strict' +const { PoolStats } = require('../util/stats.js') const DispatcherBase = require('./dispatcher-base') const FixedQueue = require('./fixed-queue') const { kConnected, kSize, kRunning, kPending, kQueued, kBusy, kFree, kUrl, kClose, kDestroy, kDispatch } = require('../core/symbols') -const PoolStats = require('./pool-stats') const kClients = Symbol('clients') const kNeedDrain = Symbol('needDrain') @@ -16,7 +16,6 @@ const kOnConnectionError = Symbol('onConnectionError') const kGetDispatcher = Symbol('get dispatcher') const kAddClient = Symbol('add client') const kRemoveClient = Symbol('remove client') -const kStats = Symbol('stats') class PoolBase extends DispatcherBase { constructor () { @@ -67,8 +66,6 @@ class PoolBase extends DispatcherBase { this[kOnConnectionError] = (origin, targets, err) => { pool.emit('connectionError', origin, [pool, ...targets], err) } - - this[kStats] = new PoolStats(this) } get [kBusy] () { @@ -108,7 +105,7 @@ class PoolBase extends DispatcherBase { } get stats () { - return this[kStats] + return new PoolStats(this) } async [kClose] () { diff --git a/lib/dispatcher/pool-stats.js b/lib/dispatcher/pool-stats.js deleted file mode 100644 index c739211f098..00000000000 --- a/lib/dispatcher/pool-stats.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict' - -const { kFree, kConnected, kPending, kQueued, kRunning, kSize } = require('../core/symbols') -const kPool = Symbol('pool') - -class PoolStats { - constructor (pool) { - this[kPool] = pool - } - - get connected () { - return this[kPool][kConnected] - } - - get free () { - return this[kPool][kFree] - } - - get pending () { - return this[kPool][kPending] - } - - get queued () { - return this[kPool][kQueued] - } - - get running () { - return this[kPool][kRunning] - } - - get size () { - return this[kPool][kSize] - } -} - -module.exports = PoolStats diff --git a/lib/util/stats.js b/lib/util/stats.js new file mode 100644 index 00000000000..a13132e4ec8 --- /dev/null +++ b/lib/util/stats.js @@ -0,0 +1,32 @@ +'use strict' + +const { + kConnected, + kPending, + kRunning, + kSize, + kFree, + kQueued +} = require('../core/symbols') + +class ClientStats { + constructor (client) { + this.connected = client[kConnected] + this.pending = client[kPending] + this.running = client[kRunning] + this.size = client[kSize] + } +} + +class PoolStats { + constructor (pool) { + this.connected = pool[kConnected] + this.free = pool[kFree] + this.pending = pool[kPending] + this.queued = pool[kQueued] + this.running = pool[kRunning] + this.size = pool[kSize] + } +} + +module.exports = { ClientStats, PoolStats } diff --git a/test/client.js b/test/client.js index 6b031be173a..78bcde2cbab 100644 --- a/test/client.js +++ b/test/client.js @@ -2166,3 +2166,33 @@ test('\\n in Method', async (t) => { t.strictEqual(err.message, 'invalid request method') }) }) + +test('stats', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + t.strictEqual(client.stats.connected, true) + t.strictEqual(client.stats.pending, 1) + t.strictEqual(client.stats.running, 1) + }) + }) + + await t.completed +}) diff --git a/test/node-test/agent.js b/test/node-test/agent.js index 6f7d7ffe53c..32977df5989 100644 --- a/test/node-test/agent.js +++ b/test/node-test/agent.js @@ -808,3 +808,40 @@ test('the dispatcher is truly global', t => { assert.ok(require.resolve('../../index.js') in require.cache) assert.strictEqual(agent, undiciFresh.getGlobalDispatcher()) }) + +test('stats', async t => { + const p = tspl(t, { plan: 7 }) + const wanted = 'payload' + + const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => { + p.strictEqual('/', req.url) + p.strictEqual('GET', req.method) + res.end(wanted) + }) + + t.after(closeServerAsPromise(server)) + + const dispatcher = new Agent({ + connect: { + servername: 'agent1' + } + }) + + server.listen(0, () => { + request(`http://localhost:${server.address().port}`, { dispatcher }) + .then(({ statusCode, headers, body }) => { + p.strictEqual(statusCode, 200) + const originForStats = `http://localhost:${server.address().port}` + const agentStats = dispatcher.stats[originForStats] + p.strictEqual(agentStats.connected, 1) + p.strictEqual(agentStats.pending, 0) + p.strictEqual(agentStats.running, 0) + p.strictEqual(agentStats.size, 0) + }) + .catch(err => { + p.fail(err) + }) + }) + + await p.completed +}) diff --git a/test/pool.js b/test/pool.js index 2b847af70d3..31d74c7d061 100644 --- a/test/pool.js +++ b/test/pool.js @@ -1150,3 +1150,35 @@ test('pool destroy fails queued requests', async (t) => { }) await t.completed }) + +test('stats', async (t) => { + t = tspl(t, { plan: 11 }) + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Pool(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + t.strictEqual(client[kUrl].origin, `http://localhost:${server.address().port}`) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(client.stats.connected, 1) + t.strictEqual(client.stats.free, 0) + t.strictEqual(client.stats.pending, 0) + t.strictEqual(client.stats.queued, 0) + t.strictEqual(client.stats.running, 1) + t.strictEqual(client.stats.size, 1) + }) + }) + + await t.completed +}) diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index 3e6d9c060f8..61168a1a5bb 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -1,84 +1,126 @@ import { Duplex, Readable, Writable } from 'stream' -import { expectAssignable } from 'tsd' +import { expectAssignable, expectType } from 'tsd' import { Client, Dispatcher } from '../..' import { URL } from 'url' expectAssignable(new Client('')) expectAssignable(new Client('', {})) -expectAssignable(new Client('', { - maxRequestsPerClient: 10 -})) -expectAssignable(new Client('', { - connect: { rejectUnauthorized: false } -})) +expectAssignable( + new Client('', { + maxRequestsPerClient: 10 + }) +) +expectAssignable( + new Client('', { + connect: { rejectUnauthorized: false } + }) +) expectAssignable(new Client(new URL('http://localhost'), {})) /** * Tests for Client.Options: */ -expectAssignable(new Client('', { - maxHeaderSize: 16384 -})) -expectAssignable(new Client('', { - headersTimeout: 300e3 -})) -expectAssignable(new Client('', { - connectTimeout: 300e3 -})) -expectAssignable(new Client('', { - bodyTimeout: 300e3 -})) -expectAssignable(new Client('', { - keepAliveTimeout: 4e3 -})) -expectAssignable(new Client('', { - keepAliveMaxTimeout: 600e3 -})) -expectAssignable(new Client('', { - keepAliveTimeoutThreshold: 1e3 -})) -expectAssignable(new Client('', { - socketPath: '/var/run/docker.sock' -})) -expectAssignable(new Client('', { - pipelining: 1 -})) -expectAssignable(new Client('', { - strictContentLength: true -})) -expectAssignable(new Client('', { - maxCachedSessions: 1 -})) -expectAssignable(new Client('', { - maxRedirections: 1 -})) -expectAssignable(new Client('', { - maxRequestsPerClient: 1 -})) -expectAssignable(new Client('', { - localAddress: '127.0.0.1' -})) -expectAssignable(new Client('', { - maxResponseSize: -1 -})) -expectAssignable(new Client('', { - autoSelectFamily: true -})) -expectAssignable(new Client('', { - autoSelectFamilyAttemptTimeout: 300e3 -})) -expectAssignable(new Client('', { - interceptors: { - Client: [(dispatcher) => { - expectAssignable(dispatcher) - return (opts, handlers) => { - expectAssignable(opts) - expectAssignable(handlers) - return dispatcher(opts, handlers) - } - }] - } -})) +expectAssignable( + new Client('', { + maxHeaderSize: 16384 + }) +) +expectAssignable( + new Client('', { + headersTimeout: 300e3 + }) +) +expectAssignable( + new Client('', { + connectTimeout: 300e3 + }) +) +expectAssignable( + new Client('', { + bodyTimeout: 300e3 + }) +) +expectAssignable( + new Client('', { + keepAliveTimeout: 4e3 + }) +) +expectAssignable( + new Client('', { + keepAliveMaxTimeout: 600e3 + }) +) +expectAssignable( + new Client('', { + keepAliveTimeoutThreshold: 1e3 + }) +) +expectAssignable( + new Client('', { + socketPath: '/var/run/docker.sock' + }) +) +expectAssignable( + new Client('', { + pipelining: 1 + }) +) +expectAssignable( + new Client('', { + strictContentLength: true + }) +) +expectAssignable( + new Client('', { + maxCachedSessions: 1 + }) +) +expectAssignable( + new Client('', { + maxRedirections: 1 + }) +) +expectAssignable( + new Client('', { + maxRequestsPerClient: 1 + }) +) +expectAssignable( + new Client('', { + localAddress: '127.0.0.1' + }) +) +expectAssignable( + new Client('', { + maxResponseSize: -1 + }) +) +expectAssignable( + new Client('', { + autoSelectFamily: true + }) +) +expectAssignable( + new Client('', { + autoSelectFamilyAttemptTimeout: 300e3 + }) +) +expectAssignable( + new Client('', { + interceptors: { + Client: [ + (dispatcher) => { + expectAssignable(dispatcher) + return (opts, handlers) => { + expectAssignable(opts) + expectAssignable(handlers) + return dispatcher(opts, handlers) + } + } + ] + } + }) +) { const client = new Client('') @@ -89,85 +131,151 @@ expectAssignable(new Client('', { expectAssignable(client.destroyed) // request - expectAssignable>(client.request({ origin: '', path: '', method: 'GET' })) - expectAssignable>(client.request({ origin: new URL('http://localhost:3000'), path: '', method: 'GET' })) - expectAssignable(client.request({ origin: '', path: '', method: 'GET' }, (err, data) => { - expectAssignable(err) - expectAssignable(data) - })) - expectAssignable(client.request({ origin: new URL('http://localhost:3000'), path: '', method: 'GET' }, (err, data) => { - expectAssignable(err) - expectAssignable(data) - })) + expectAssignable>( + client.request({ origin: '', path: '', method: 'GET' }) + ) + expectAssignable>( + client.request({ + origin: new URL('http://localhost:3000'), + path: '', + method: 'GET' + }) + ) + expectAssignable( + client.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + }) + ) + expectAssignable( + client.request( + { origin: new URL('http://localhost:3000'), path: '', method: 'GET' }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + ) + ) // stream - expectAssignable>(client.stream({ origin: '', path: '', method: 'GET' }, data => { - expectAssignable(data) - return new Writable() - })) - expectAssignable>(client.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { - expectAssignable(data) - return new Writable() - })) - expectAssignable(client.stream( - { origin: '', path: '', method: 'GET' }, - data => { + expectAssignable>( + client.stream({ origin: '', path: '', method: 'GET' }, (data) => { expectAssignable(data) return new Writable() - }, - (err, data) => { - expectAssignable(err) - expectAssignable(data) - } - )) - expectAssignable(client.stream( - { origin: new URL('http://localhost'), path: '', method: 'GET' }, - data => { - expectAssignable(data) - return new Writable() - }, - (err, data) => { - expectAssignable(err) - expectAssignable(data) - } - )) + }) + ) + expectAssignable>( + client.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + (data) => { + expectAssignable(data) + return new Writable() + } + ) + ) + expectAssignable( + client.stream( + { origin: '', path: '', method: 'GET' }, + (data) => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + ) + ) + expectAssignable( + client.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + (data) => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + ) + ) // pipeline - expectAssignable(client.pipeline({ origin: '', path: '', method: 'GET' }, data => { - expectAssignable(data) - return new Readable() - })) - expectAssignable(client.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { - expectAssignable(data) - return new Readable() - })) + expectAssignable( + client.pipeline({ origin: '', path: '', method: 'GET' }, (data) => { + expectAssignable(data) + return new Readable() + }) + ) + expectAssignable( + client.pipeline( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + (data) => { + expectAssignable(data) + return new Readable() + } + ) + ) // upgrade - expectAssignable>(client.upgrade({ path: '' })) - expectAssignable>(client.upgrade({ path: '', headers: [] })) - expectAssignable>(client.upgrade({ path: '', headers: {} })) - expectAssignable>(client.upgrade({ path: '', headers: null })) - expectAssignable(client.upgrade({ path: '' }, (err, data) => { - expectAssignable(err) - expectAssignable(data) - })) + expectAssignable>( + client.upgrade({ path: '' }) + ) + expectAssignable>( + client.upgrade({ path: '', headers: [] }) + ) + expectAssignable>( + client.upgrade({ path: '', headers: {} }) + ) + expectAssignable>( + client.upgrade({ path: '', headers: null }) + ) + expectAssignable( + client.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + }) + ) // connect - expectAssignable>(client.connect({ path: '' })) - expectAssignable>(client.connect({ path: '', headers: [] })) - expectAssignable>(client.connect({ path: '', headers: {} })) - expectAssignable>(client.connect({ path: '', headers: null })) - expectAssignable(client.connect({ path: '' }, (err, data) => { - expectAssignable(err) - expectAssignable(data) - })) + expectAssignable>( + client.connect({ path: '' }) + ) + expectAssignable>( + client.connect({ path: '', headers: [] }) + ) + expectAssignable>( + client.connect({ path: '', headers: {} }) + ) + expectAssignable>( + client.connect({ path: '', headers: null }) + ) + expectAssignable( + client.connect({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + }) + ) // dispatch - expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET' }, {})) - expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: [] }, {})) - expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: {} }, {})) - expectAssignable(client.dispatch({ origin: '', path: '', method: 'GET', headers: null }, {})) - expectAssignable(client.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {})) + expectAssignable( + client.dispatch({ origin: '', path: '', method: 'GET' }, {}) + ) + expectAssignable( + client.dispatch({ origin: '', path: '', method: 'GET', headers: [] }, {}) + ) + expectAssignable( + client.dispatch({ origin: '', path: '', method: 'GET', headers: {} }, {}) + ) + expectAssignable( + client.dispatch({ origin: '', path: '', method: 'GET', headers: null }, {}) + ) + expectAssignable( + client.dispatch( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + {} + ) + ) // close expectAssignable>(client.close()) @@ -180,4 +288,10 @@ expectAssignable(new Client('', { expectAssignable(client.destroy(() => {})) expectAssignable(client.destroy(new Error(), () => {})) expectAssignable(client.destroy(null, () => {})) + + // stats + expectType(client.stats.connected) + expectType(client.stats.pending) + expectType(client.stats.running) + expectType(client.stats.size) } diff --git a/types/agent.d.ts b/types/agent.d.ts index ee313b5209b..2132560744a 100644 --- a/types/agent.d.ts +++ b/types/agent.d.ts @@ -1,6 +1,8 @@ import { URL } from 'url' import Pool from './pool' import Dispatcher from './dispatcher' +import TClientStats from './client-stats' +import TPoolStats from './pool-stats' export default Agent @@ -12,6 +14,8 @@ declare class Agent extends Dispatcher { destroyed: boolean /** Dispatches a request. */ dispatch (options: Agent.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean + /** Aggregate stats for a Agent by origin. */ + readonly stats: Record } declare namespace Agent { diff --git a/types/client-stats.d.ts b/types/client-stats.d.ts new file mode 100644 index 00000000000..ad9bd8482df --- /dev/null +++ b/types/client-stats.d.ts @@ -0,0 +1,15 @@ +import Client from './client' + +export default ClientStats + +declare class ClientStats { + constructor (pool: Client) + /** If socket has open connection. */ + connected: boolean + /** Number of open socket connections in this client that do not have an active request. */ + pending: number + /** Number of currently active requests of this client. */ + running: number + /** Number of active, pending, or queued requests of this client. */ + size: number +} diff --git a/types/client.d.ts b/types/client.d.ts index 55bfcef5866..088a673eb52 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -1,6 +1,7 @@ import { URL } from 'url' import Dispatcher from './dispatcher' import buildConnector from './connector' +import TClientStats from './client-stats' type ClientConnectOptions = Omit @@ -15,6 +16,8 @@ export class Client extends Dispatcher { closed: boolean /** `true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. */ destroyed: boolean + /** Aggregate stats for a Client. */ + readonly stats: TClientStats // Override dispatcher APIs. override connect ( @@ -84,13 +87,13 @@ export declare namespace Client { /** * @description Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. * @default false - */ + */ allowH2?: boolean; /** * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. * @default 100 - */ - maxConcurrentStreams?: number + */ + maxConcurrentStreams?: number; } export interface SocketInfo { localAddress?: string