From 41655b6a2aceeb909c318ab4a113ab582cf08220 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 11:46:05 +0100 Subject: [PATCH 01/20] feat: circuit breaker for usage reporting --- packages/libraries/core/package.json | 2 + packages/libraries/core/src/client/agent.ts | 51 +++++++++++++++++-- .../libraries/core/src/client/http-client.ts | 15 +++++- pnpm-lock.yaml | 49 ++++++++++++++---- 4 files changed, 100 insertions(+), 17 deletions(-) diff --git a/packages/libraries/core/package.json b/packages/libraries/core/package.json index bfd3cc6d438..76b8eeb5a0e 100644 --- a/packages/libraries/core/package.json +++ b/packages/libraries/core/package.json @@ -50,6 +50,7 @@ "async-retry": "^1.3.3", "js-md5": "0.8.3", "lodash.sortby": "^4.7.0", + "opossum": "^9.0.0", "tiny-lru": "^8.0.2" }, "devDependencies": { @@ -58,6 +59,7 @@ "@types/async-retry": "1.4.8", "@types/js-md5": "0.8.0", "@types/lodash.sortby": "4.7.9", + "@types/opossum": "8.1.9", "graphql": "16.9.0", "nock": "14.0.10", "tslib": "2.8.1", diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 2ccc0d205b2..e0777e8ba63 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -1,9 +1,33 @@ +import CircuitBreaker from 'opossum'; +import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { version } from '../version.js'; import { http } from './http-client.js'; import type { Logger } from './types.js'; type ReadOnlyResponse = Pick; +export type AgentCircuitBreakerConfiguration = { + /** after which time a request should be treated as a timeout in milleseconds */ + timeout: number; + /** percentage after what the circuit breaker should kick in. */ + errorThresholdPercentage: number; + /** count of requests before starting evaluating. */ + volumeThreshold: number; + /** after what time the circuit breaker is resetted in milliseconds */ + resetTimeout: number; +}; + +const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { + // if call takes > 5s, count as a failure + timeout: 5000, + // trip if 50% of calls fail + errorThresholdPercentage: 50, + // need at least 5 calls before evaluating + volumeThreshold: 5, + // after 30s, try half-open state + resetTimeout: 30000, +}; + export interface AgentOptions { enabled?: boolean; name?: string; @@ -48,7 +72,11 @@ export interface AgentOptions { * WHATWG Compatible fetch implementation * used by the agent to send reports */ - fetch?: typeof fetch; + fetch?: typeof defaultFetch; + /** + * Circuit Breaker Configuration + */ + circuitBreaker?: AgentCircuitBreakerConfiguration; } export function createAgent( @@ -77,13 +105,14 @@ export function createAgent( maxSize: 25, logger: console, name: 'hive-client', + circuitBreaker: defaultCircuitBreakerConfiguration, version, ...pluginOptions, }; const enabled = options.enabled !== false; - let timeoutID: any = null; + let timeoutID: ReturnType | null = null; function schedule() { if (timeoutID) { @@ -133,20 +162,22 @@ export function createAgent( if (data.size() >= options.maxSize) { debugLog('Sending immediately'); - setImmediate(() => send({ throwOnError: false, skipSchedule: true })); + setImmediate(() => breaker.fire({ throwOnError: false, skipSchedule: true })); } } function sendImmediately(event: TEvent): Promise { data.set(event); debugLog('Sending immediately'); - return send({ throwOnError: true, skipSchedule: true }); + return breaker.fire({ throwOnError: true, skipSchedule: true }); } async function send(sendOptions?: { throwOnError?: boolean; skipSchedule: boolean; }): Promise { + const signal: AbortSignal = breaker.getSignal(); + if (!data.size() || !enabled) { if (!sendOptions?.skipSchedule) { schedule(); @@ -176,6 +207,7 @@ export function createAgent( }, logger: options.logger, fetchImplementation: pluginOptions.fetch, + signal, }) .then(res => { debugLog(`Report sent!`); @@ -209,12 +241,21 @@ export function createAgent( await Promise.all(inProgressCaptures); } - await send({ + await breaker.fire({ skipSchedule: true, throwOnError: false, }); } + const breaker = new CircuitBreaker(send, { + ...options.circuitBreaker, + autoRenewAbortController: true, + }); + + breaker.on('open', () => errorLog('circuit opened - backend unreachable')); + breaker.on('halfOpen', () => debugLog('testing backend connectivity')); + breaker.on('close', () => debugLog('backend recovered - circuit closed')); + return { capture, sendImmediately, diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 24908656042..8052f7ee772 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -21,6 +21,8 @@ interface SharedConfig { * @default {response => response.ok} **/ isRequestOk?: ResponseAssertFunction; + /** Optional abort signal */ + signal?: AbortSignal; } /** @@ -78,6 +80,8 @@ export async function makeFetchCall( * @default {response => response.ok} **/ isRequestOk?: ResponseAssertFunction; + /** Optional abort signal */ + signal?: AbortSignal; }, ): Promise { const logger = config.logger; @@ -104,7 +108,10 @@ export async function makeFetchCall( ); const getDuration = measureTime(); - const signal = AbortSignal.timeout(config.timeout ?? 20_000); + const timeoutSignal = AbortSignal.timeout(config.timeout ?? 20_000); + const signal = config.signal + ? AbortSignal.any([config.signal, timeoutSignal]) + : timeoutSignal; const response = await (config.fetchImplementation ?? fetch)(endpoint, { method: config.method, @@ -135,6 +142,12 @@ export async function makeFetchCall( throw new Error(`Unexpected HTTP error. (x-request-id=${requestId})`, { cause: error }); }); + if (config.signal?.aborted === true) { + // TODO: maybe log some message? + bail(new Error('Request aborted.')); + return; + } + if (isRequestOk(response)) { logger?.info( `${config.method} ${endpoint} (x-request-id=${requestId}) succeeded with status ${response.status} ${getDuration()}.`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df9c802e539..20fd2ded6aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,9 @@ importers: lodash.sortby: specifier: ^4.7.0 version: 4.7.0 + opossum: + specifier: ^9.0.0 + version: 9.0.0 tiny-lru: specifier: ^8.0.2 version: 8.0.2 @@ -541,6 +544,9 @@ importers: '@types/lodash.sortby': specifier: 4.7.9 version: 4.7.9 + '@types/opossum': + specifier: 8.1.9 + version: 8.1.9 graphql: specifier: 16.9.0 version: 16.9.0 @@ -1436,7 +1442,7 @@ importers: devDependencies: '@graphql-inspector/core': specifier: 6.4.1 - version: 6.4.1(graphql@16.9.0) + version: 6.4.1(graphql@16.11.0) '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -3789,6 +3795,7 @@ packages: '@fastify/vite@6.0.7': resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==} + bundledDependencies: [] '@floating-ui/core@1.2.6': resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==} @@ -8892,6 +8899,9 @@ packages: '@types/object-hash@3.0.6': resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} + '@types/opossum@8.1.9': + resolution: {integrity: sha512-Jm/tYxuJFefiwRYs+/EOsUP3ktk0c8siMgAHPLnA4PXF4wKghzcjqf88dY+Xii5jId5Txw4JV0FMKTpjbd7KJA==} + '@types/oracledb@6.5.2': resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} @@ -14270,6 +14280,10 @@ packages: peerDependencies: '@opentelemetry/api': ^1.6.0 + opossum@9.0.0: + resolution: {integrity: sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw==} + engines: {node: ^24 || ^22 || ^20} + optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -17908,8 +17922,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18061,11 +18075,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18104,6 +18118,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -18280,11 +18295,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18323,7 +18338,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -18474,7 +18488,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -18650,7 +18664,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -18945,7 +18959,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -20629,6 +20643,13 @@ snapshots: object-inspect: 1.12.3 tslib: 2.6.2 + '@graphql-inspector/core@6.4.1(graphql@16.11.0)': + dependencies: + dependency-graph: 1.0.0 + graphql: 16.11.0 + object-inspect: 1.13.2 + tslib: 2.6.2 + '@graphql-inspector/core@6.4.1(graphql@16.9.0)': dependencies: dependency-graph: 1.0.0 @@ -27217,6 +27238,10 @@ snapshots: '@types/object-hash@3.0.6': {} + '@types/opossum@8.1.9': + dependencies: + '@types/node': 22.10.5 + '@types/oracledb@6.5.2': dependencies: '@types/node': 22.10.5 @@ -33893,6 +33918,8 @@ snapshots: transitivePeerDependencies: - supports-color + opossum@9.0.0: {} + optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 From 6700d59ac286f1faef5eff11c3ac89ac6406d2ef Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 12:10:09 +0100 Subject: [PATCH 02/20] config --- packages/libraries/core/src/client/agent.ts | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index e0777e8ba63..00d813af9ec 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -162,14 +162,14 @@ export function createAgent( if (data.size() >= options.maxSize) { debugLog('Sending immediately'); - setImmediate(() => breaker.fire({ throwOnError: false, skipSchedule: true })); + setImmediate(() => sendFromBreaker({ throwOnError: false, skipSchedule: true })); } } function sendImmediately(event: TEvent): Promise { data.set(event); debugLog('Sending immediately'); - return breaker.fire({ throwOnError: true, skipSchedule: true }); + return sendFromBreaker({ throwOnError: true, skipSchedule: true }); } async function send(sendOptions?: { @@ -241,7 +241,7 @@ export function createAgent( await Promise.all(inProgressCaptures); } - await breaker.fire({ + await sendFromBreaker({ skipSchedule: true, throwOnError: false, }); @@ -252,6 +252,25 @@ export function createAgent( autoRenewAbortController: true, }); + async function sendFromBreaker(...args: Parameters) { + try { + return await breaker.fire(...args); + } catch (err: unknown) { + if (err instanceof Error && 'code' in err) { + if (err.code === 'EOPENBREAKER') { + debugLog('Sending report skipped. (breaker circuit open)'); + return null; + } + if (err.code === 'ETIMEDOUT') { + debugLog('Sending report skipped. (metric timed out)'); + return null; + } + } + + throw err; + } + } + breaker.on('open', () => errorLog('circuit opened - backend unreachable')); breaker.on('halfOpen', () => debugLog('testing backend connectivity')); breaker.on('close', () => debugLog('backend recovered - circuit closed')); From 313884c9dfee2a0ac8675df29d8ef0183e55f2a3 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 12:16:12 +0100 Subject: [PATCH 03/20] fix --- packages/libraries/core/src/client/agent.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 00d813af9ec..882e80b5350 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -176,6 +176,7 @@ export function createAgent( throwOnError?: boolean; skipSchedule: boolean; }): Promise { + // @ts-ignore missing definition in typedefs const signal: AbortSignal = breaker.getSignal(); if (!data.size() || !enabled) { @@ -271,9 +272,13 @@ export function createAgent( } } - breaker.on('open', () => errorLog('circuit opened - backend unreachable')); - breaker.on('halfOpen', () => debugLog('testing backend connectivity')); - breaker.on('close', () => debugLog('backend recovered - circuit closed')); + breaker.on('open', () => + errorLog('[breaker circuit] circuit opened - backend seems unreachable.'), + ); + breaker.on('halfOpen', () => + debugLog('[breaker circuit] circuit half open - testing backend connectivity'), + ); + breaker.on('close', () => debugLog('[breaker circuit] circuit closed - backend recovered ')); return { capture, From cfc8ab72b2a5b1bea9d7472f773472841f5bd9b6 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 12:50:21 +0100 Subject: [PATCH 04/20] g --- packages/libraries/core/src/client/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 882e80b5350..34f84bd004d 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -176,7 +176,7 @@ export function createAgent( throwOnError?: boolean; skipSchedule: boolean; }): Promise { - // @ts-ignore missing definition in typedefs + // @ts-expect-error missing definition in typedefs for `opposum` const signal: AbortSignal = breaker.getSignal(); if (!data.size() || !enabled) { From 47b16b39d895199262bef85ba94a0972a980f892 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 13:01:32 +0100 Subject: [PATCH 05/20] h --- packages/libraries/core/src/client/http-client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 8052f7ee772..c788ecf0dff 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -143,9 +143,10 @@ export async function makeFetchCall( }); if (config.signal?.aborted === true) { + const error = config.signal.reason ?? new Error('Request aborted externally.'); // TODO: maybe log some message? - bail(new Error('Request aborted.')); - return; + bail(error); + throw error; } if (isRequestOk(response)) { From 0cc54ea4cef42d3587bd8cba2dbdee96a7a5dfda Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 14:35:42 +0100 Subject: [PATCH 06/20] curcuit break on this level --- packages/libraries/core/src/client/agent.ts | 56 +++++++++++---------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 34f84bd004d..91eafd3d775 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -162,23 +162,42 @@ export function createAgent( if (data.size() >= options.maxSize) { debugLog('Sending immediately'); - setImmediate(() => sendFromBreaker({ throwOnError: false, skipSchedule: true })); + setImmediate(() => send({ throwOnError: false, skipSchedule: true })); } } function sendImmediately(event: TEvent): Promise { data.set(event); debugLog('Sending immediately'); - return sendFromBreaker({ throwOnError: true, skipSchedule: true }); + return send({ throwOnError: true, skipSchedule: true }); + } + + async function sendHTTPCall(buffer: string | Buffer) { + // @ts-expect-error missing definition in typedefs for `opposum` + const signal: AbortSignal = breaker.getSignal(); + return await http.post(options.endpoint, buffer, { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + Authorization: `Bearer ${options.token}`, + 'User-Agent': `${options.name}/${options.version}`, + ...headers(), + }, + timeout: options.timeout, + retry: { + retries: options.maxRetries, + factor: 2, + }, + logger: options.logger, + fetchImplementation: pluginOptions.fetch, + signal, + }); } async function send(sendOptions?: { throwOnError?: boolean; skipSchedule: boolean; }): Promise { - // @ts-expect-error missing definition in typedefs for `opposum` - const signal: AbortSignal = breaker.getSignal(); - if (!data.size() || !enabled) { if (!sendOptions?.skipSchedule) { schedule(); @@ -192,24 +211,7 @@ export function createAgent( data.clear(); debugLog(`Sending report (queue ${dataToSend})`); - const response = await http - .post(options.endpoint, buffer, { - headers: { - accept: 'application/json', - 'content-type': 'application/json', - Authorization: `Bearer ${options.token}`, - 'User-Agent': `${options.name}/${options.version}`, - ...headers(), - }, - timeout: options.timeout, - retry: { - retries: options.maxRetries, - factor: 2, - }, - logger: options.logger, - fetchImplementation: pluginOptions.fetch, - signal, - }) + const response = sendFromBreaker(buffer) .then(res => { debugLog(`Report sent!`); return res; @@ -242,13 +244,13 @@ export function createAgent( await Promise.all(inProgressCaptures); } - await sendFromBreaker({ + await send({ skipSchedule: true, throwOnError: false, }); } - const breaker = new CircuitBreaker(send, { + const breaker = new CircuitBreaker(sendHTTPCall, { ...options.circuitBreaker, autoRenewAbortController: true, }); @@ -259,11 +261,11 @@ export function createAgent( } catch (err: unknown) { if (err instanceof Error && 'code' in err) { if (err.code === 'EOPENBREAKER') { - debugLog('Sending report skipped. (breaker circuit open)'); + debugLog('[breaker circuit] circuit open - sending report skipped'); return null; } if (err.code === 'ETIMEDOUT') { - debugLog('Sending report skipped. (metric timed out)'); + debugLog('[breaker circuit] circuit open - sending report aborted - timed out'); return null; } } From 9de6fec65a7304753bb090a810226882cf379016 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 14:39:09 +0100 Subject: [PATCH 07/20] add playground --- .../core/playground/agent-circuit-breaker.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/libraries/core/playground/agent-circuit-breaker.ts diff --git a/packages/libraries/core/playground/agent-circuit-breaker.ts b/packages/libraries/core/playground/agent-circuit-breaker.ts new file mode 100644 index 00000000000..621887b020b --- /dev/null +++ b/packages/libraries/core/playground/agent-circuit-breaker.ts @@ -0,0 +1,53 @@ +/** + * + * Just a small playground to play around with different scenarios arounf the agent. + * You can run it like this: `bun run --watch packages/libraries/core/playground/agent-circuit-breaker.ts` + */ + +import { createAgent } from '../src/client/agent.js'; + +let data: Array<{}> = []; + +const agent = createAgent<{}>( + { + debug: true, + endpoint: 'http://127.0.0.1', + token: 'noop', + async fetch(url, opts) { + // throw new Error('FAIL FAIL'); + console.log('SENDING!'); + return new Response('ok', { + status: 200, + }); + }, + circuitBreaker: { + timeout: 1_000, + errorThresholdPercentage: 1, + resetTimeout: 10_000, + volumeThreshold: 0, + }, + maxSize: 1, + maxRetries: 0, + }, + { + body() { + data = []; + return String(data); + }, + data: { + clear() { + data = []; + }, + size() { + return data.length; + }, + set(d) { + data.push(d); + }, + }, + }, +); + +setInterval(() => { + agent.capture({}); +}, 1_000); From 0eaa641ac2cd83d81e9ec3e3a59b280ce129eb7b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 15:02:02 +0100 Subject: [PATCH 08/20] changeset --- .changeset/beige-teams-spend.md | 43 ++++++++++++++++++++ packages/libraries/core/src/client/agent.ts | 45 ++++++++++++--------- 2 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 .changeset/beige-teams-spend.md diff --git a/.changeset/beige-teams-spend.md b/.changeset/beige-teams-spend.md new file mode 100644 index 00000000000..3eb0d14275e --- /dev/null +++ b/.changeset/beige-teams-spend.md @@ -0,0 +1,43 @@ +--- +'@graphql-hive/envelop': minor +'@graphql-hive/apollo': minor +'@graphql-hive/core': minor +'@graphql-hive/yoga': minor +--- + +Support circuit breaking for usage reporting. + +Circuit breaking is a fault-tolerance pattern that prevents a system from repeatedly calling a failing service. When errors or timeouts exceed a set threshold, the circuit “opens,” blocking further requests until the service recovers. + +This ensures that during a network issue or outage, the service using the Hive SDK remains healthy and is not overwhelmed by failed usage reports or repeated retries. + +```ts +import { createClient } from "@graphql-hive/core" + +const client = createClient({ + agent: { + circuitBreaker: { + /** + * Count of requests before starting evaluating. + * Default: 5 + */ + volumeThreshold: 5, + /** + * After which time a request should be treated as a timeout in milleseconds + * Default: 5_000 + */ + timeout: 5_000, + /** + * Percentage of requests failing before the circuit breaker kicks in. + * Default: 50 + */ + errorThresholdPercentage: 1, + /** + * After what time the circuit breaker is attempting to retry sending requests in milliseconds + * Default: 30_000 + */ + resetTimeout: 10_000, + }, + } +}) +``` diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 91eafd3d775..e7f2dd40483 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -3,29 +3,38 @@ import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { version } from '../version.js'; import { http } from './http-client.js'; import type { Logger } from './types.js'; +import { createHiveLogger } from './utils.js'; type ReadOnlyResponse = Pick; export type AgentCircuitBreakerConfiguration = { - /** after which time a request should be treated as a timeout in milleseconds */ + /** + * After which time a request should be treated as a timeout in milleseconds + * Default: 5_000 + */ timeout: number; - /** percentage after what the circuit breaker should kick in. */ + /** + * Percentage after what the circuit breaker should kick in. + * Default: 50 + */ errorThresholdPercentage: number; - /** count of requests before starting evaluating. */ + /** + * Count of requests before starting evaluating. + * Default: 5 + */ volumeThreshold: number; - /** after what time the circuit breaker is resetted in milliseconds */ + /** + * After what time the circuit breaker is attempting to retry sending requests in milliseconds + * Default: 30_000 + */ resetTimeout: number; }; const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { - // if call takes > 5s, count as a failure - timeout: 5000, - // trip if 50% of calls fail + timeout: 5_000, errorThresholdPercentage: 50, - // need at least 5 calls before evaluating volumeThreshold: 5, - // after 30s, try half-open state - resetTimeout: 30000, + resetTimeout: 30_000, }; export interface AgentOptions { @@ -103,11 +112,11 @@ export function createAgent( maxRetries: 3, sendInterval: 10_000, maxSize: 25, - logger: console, name: 'hive-client', circuitBreaker: defaultCircuitBreakerConfiguration, version, ...pluginOptions, + logger: createHiveLogger(pluginOptions.logger ?? console, '[agent]'), }; const enabled = options.enabled !== false; @@ -255,17 +264,19 @@ export function createAgent( autoRenewAbortController: true, }); + const breakerLogger = createHiveLogger(options.logger, ' [circuit breaker]'); + async function sendFromBreaker(...args: Parameters) { try { return await breaker.fire(...args); } catch (err: unknown) { if (err instanceof Error && 'code' in err) { if (err.code === 'EOPENBREAKER') { - debugLog('[breaker circuit] circuit open - sending report skipped'); + breakerLogger.info('circuit open - sending report skipped'); return null; } if (err.code === 'ETIMEDOUT') { - debugLog('[breaker circuit] circuit open - sending report aborted - timed out'); + breakerLogger.info('circuit open - sending report aborted - timed out'); return null; } } @@ -274,13 +285,11 @@ export function createAgent( } } - breaker.on('open', () => - errorLog('[breaker circuit] circuit opened - backend seems unreachable.'), - ); + breaker.on('open', () => breakerLogger.error('circuit opened - backend seems unreachable.')); breaker.on('halfOpen', () => - debugLog('[breaker circuit] circuit half open - testing backend connectivity'), + breakerLogger.info('circuit half open - testing backend connectivity'), ); - breaker.on('close', () => debugLog('[breaker circuit] circuit closed - backend recovered ')); + breaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); return { capture, From 4a89f672be29afdd8c5401dd7da81f1e804fd9ee Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 15:08:27 +0100 Subject: [PATCH 09/20] lint gods amogus --- packages/libraries/core/playground/agent-circuit-breaker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libraries/core/playground/agent-circuit-breaker.ts b/packages/libraries/core/playground/agent-circuit-breaker.ts index 621887b020b..0a89f6e0868 100644 --- a/packages/libraries/core/playground/agent-circuit-breaker.ts +++ b/packages/libraries/core/playground/agent-circuit-breaker.ts @@ -13,7 +13,7 @@ const agent = createAgent<{}>( debug: true, endpoint: 'http://127.0.0.1', token: 'noop', - async fetch(url, opts) { + async fetch(_url, _opts) { // throw new Error('FAIL FAIL'); console.log('SENDING!'); return new Response('ok', { From 0bba384730d61b21202a197f7b5045753e46b9af Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 15:12:11 +0100 Subject: [PATCH 10/20] Apply suggestion from @n1ru4l --- packages/libraries/core/src/client/http-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index c788ecf0dff..6b9f0989933 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -144,7 +144,6 @@ export async function makeFetchCall( if (config.signal?.aborted === true) { const error = config.signal.reason ?? new Error('Request aborted externally.'); - // TODO: maybe log some message? bail(error); throw error; } From 491968401bd2429cfbdf5a800c9d388d16d1a9fe Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 15:19:10 +0100 Subject: [PATCH 11/20] update fixtures --- packages/libraries/core/tests/usage.spec.ts | 58 ++++++++++----------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/libraries/core/tests/usage.spec.ts b/packages/libraries/core/tests/usage.spec.ts index 3b11d786aa7..d44942a6dce 100644 --- a/packages/libraries/core/tests/usage.spec.ts +++ b/packages/libraries/core/tests/usage.spec.ts @@ -165,11 +165,11 @@ test('should send data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Disposing - [INF] [hive][usage] Sending report (queue 1) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). - [INF] [hive][usage] Report sent! + [INF] [hive][usage][agent] Disposing + [INF] [hive][usage][agent] Sending report (queue 1) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). + [INF] [hive][usage][agent] Report sent! `); // Map @@ -275,11 +275,11 @@ test('should send data to Hive (deprecated endpoint)', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Disposing - [INF] [hive][usage] Sending report (queue 1) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). - [INF] [hive][usage] Report sent! + [INF] [hive][usage][agent] Disposing + [INF] [hive][usage][agent] Sending report (queue 1) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). + [INF] [hive][usage][agent] Report sent! `); // Map @@ -366,11 +366,11 @@ test('should not leak the exception', { retry: 3 }, async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Sending report (queue 1) - [INF] [hive][usage] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) - [ERR] [hive][usage] Error: getaddrinfo ENOTFOUND 404.localhost.noop - [ERR] [hive][usage] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed (666ms). getaddrinfo ENOTFOUND 404.localhost.noop - [INF] [hive][usage] Disposing + [INF] [hive][usage][agent] Sending report (queue 1) + [INF] [hive][usage][agent] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) + [ERR] [hive][usage][agent] Error: getaddrinfo ENOTFOUND 404.localhost.noop + [ERR] [hive][usage][agent] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed (666ms). getaddrinfo ENOTFOUND 404.localhost.noop + [INF] [hive][usage][agent] Disposing `); }); @@ -536,11 +536,11 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Disposing - [INF] [hive][usage] Sending report (queue 2) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). - [INF] [hive][usage] Report sent! + [INF] [hive][usage][agent] Disposing + [INF] [hive][usage][agent] Sending report (queue 2) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). + [INF] [hive][usage][agent] Report sent! `); // Map @@ -640,11 +640,11 @@ test('should not send excluded operation name data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Disposing - [INF] [hive][usage] Sending report (queue 2) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). - [INF] [hive][usage] Report sent! + [INF] [hive][usage][agent] Disposing + [INF] [hive][usage][agent] Sending report (queue 2) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). + [INF] [hive][usage][agent] Report sent! `); // Map @@ -741,10 +741,10 @@ test('retry on non-200', async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage] Sending report (queue 1) - [INF] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) - [ERR] [hive][usage] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed with status 500 (666ms): No no no - [INF] [hive][usage] Disposing + [INF] [hive][usage][agent] Sending report (queue 1) + [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) + [ERR] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed with status 500 (666ms): No no no + [INF] [hive][usage][agent] Disposing `); }); From ed3e4cbde360e6ebaf4ecb8847e985d0daba74f4 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 16:06:25 +0100 Subject: [PATCH 12/20] cloudflare lol (#7261) --- packages/libraries/core/package.json | 1 + packages/libraries/core/src/client/agent.ts | 80 +++++++++++++++---- .../libraries/core/src/client/http-client.ts | 5 +- packages/libraries/core/tests/usage.spec.ts | 12 +++ pnpm-lock.yaml | 47 ++++++----- 5 files changed, 108 insertions(+), 37 deletions(-) diff --git a/packages/libraries/core/package.json b/packages/libraries/core/package.json index 76b8eeb5a0e..91204035344 100644 --- a/packages/libraries/core/package.json +++ b/packages/libraries/core/package.json @@ -45,6 +45,7 @@ "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { + "@graphql-hive/signal": "^2.0.0", "@graphql-tools/utils": "^10.0.0", "@whatwg-node/fetch": "^0.10.6", "async-retry": "^1.3.3", diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index e7f2dd40483..3beac5dad24 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -1,4 +1,4 @@ -import CircuitBreaker from 'opossum'; +import type CircuitBreaker from 'opossum'; import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { version } from '../version.js'; import { http } from './http-client.js'; @@ -181,9 +181,8 @@ export function createAgent( return send({ throwOnError: true, skipSchedule: true }); } - async function sendHTTPCall(buffer: string | Buffer) { - // @ts-expect-error missing definition in typedefs for `opposum` - const signal: AbortSignal = breaker.getSignal(); + async function sendHTTPCall(buffer: string | Buffer): Promise { + const signal = breaker.getSignal(); return await http.post(options.endpoint, buffer, { headers: { accept: 'application/json', @@ -259,14 +258,53 @@ export function createAgent( }); } - const breaker = new CircuitBreaker(sendHTTPCall, { - ...options.circuitBreaker, - autoRenewAbortController: true, - }); + /** + * We support Cloudflare, which does not has the `events` module. + * So we lazy load opossum which has `events` as a dependency. + */ + const breakerLogger = createHiveLogger(options.logger, '[circuit breaker]'); + + let breaker: CircuitBreakerInterface< + Parameters, + ReturnType + >; + + breakerLogger.info('initialize circuit breaker'); + const loadCircuitBreakerPromise = loadCircuitBreaker( + CircuitBreaker => { + breakerLogger.info('started'); + const realBreaker = new CircuitBreaker(sendHTTPCall, { + ...options.circuitBreaker, + autoRenewAbortController: true, + }); - const breakerLogger = createHiveLogger(options.logger, ' [circuit breaker]'); + realBreaker.on('open', () => + breakerLogger.error('circuit opened - backend seems unreachable.'), + ); + realBreaker.on('halfOpen', () => + breakerLogger.info('circuit half open - testing backend connectivity'), + ); + realBreaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); + + // @ts-expect-error missing definition in typedefs for `opposum` + breaker = realBreaker; + }, + () => { + breakerLogger.info('circuit breaker not supported on platform'); + breaker = { + getSignal() { + return undefined; + }, + fire: sendHTTPCall, + }; + }, + ); async function sendFromBreaker(...args: Parameters) { + if (!breaker) { + await loadCircuitBreakerPromise; + } + try { return await breaker.fire(...args); } catch (err: unknown) { @@ -285,15 +323,27 @@ export function createAgent( } } - breaker.on('open', () => breakerLogger.error('circuit opened - backend seems unreachable.')); - breaker.on('halfOpen', () => - breakerLogger.info('circuit half open - testing backend connectivity'), - ); - breaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); - return { capture, sendImmediately, dispose, }; } + +type CircuitBreakerInterface = { + fire(...args: TI): TR; + getSignal(): AbortSignal | undefined; +}; + +async function loadCircuitBreaker( + success: (breaker: typeof CircuitBreaker) => void, + error: () => void, +): Promise { + const packageName = 'opossum'; + try { + const module = await import(packageName); + success(module.default); + } catch (err) { + error(); + } +} diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 6b9f0989933..2c6bd7463f2 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -1,4 +1,5 @@ import asyncRetry from 'async-retry'; +import { abortSignalAny } from '@graphql-hive/signal'; import { crypto, fetch, URL } from '@whatwg-node/fetch'; import type { Logger } from './types.js'; @@ -109,9 +110,7 @@ export async function makeFetchCall( const getDuration = measureTime(); const timeoutSignal = AbortSignal.timeout(config.timeout ?? 20_000); - const signal = config.signal - ? AbortSignal.any([config.signal, timeoutSignal]) - : timeoutSignal; + const signal = config.signal ? abortSignalAny([config.signal, timeoutSignal]) : timeoutSignal; const response = await (config.fetchImplementation ?? fetch)(endpoint, { method: config.method, diff --git a/packages/libraries/core/tests/usage.spec.ts b/packages/libraries/core/tests/usage.spec.ts index d44942a6dce..8ff097b1b60 100644 --- a/packages/libraries/core/tests/usage.spec.ts +++ b/packages/libraries/core/tests/usage.spec.ts @@ -165,6 +165,8 @@ test('should send data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -275,6 +277,8 @@ test('should send data to Hive (deprecated endpoint)', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -366,6 +370,8 @@ test('should not leak the exception', { retry: 3 }, async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) [ERR] [hive][usage][agent] Error: getaddrinfo ENOTFOUND 404.localhost.noop @@ -536,7 +542,9 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker [INF] [hive][usage][agent] Disposing + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 2) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). @@ -640,6 +648,8 @@ test('should not send excluded operation name data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 2) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -741,6 +751,8 @@ test('retry on non-200', async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` + [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker + [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) [ERR] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed with status 500 (666ms): No no no diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20fd2ded6aa..02a526edd30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -507,6 +507,9 @@ importers: packages/libraries/core: dependencies: + '@graphql-hive/signal': + specifier: ^2.0.0 + version: 2.0.0 '@graphql-tools/utils': specifier: ^10.0.0 version: 10.5.6(graphql@16.9.0) @@ -3980,6 +3983,10 @@ packages: resolution: {integrity: sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==} engines: {node: '>=18.0.0'} + '@graphql-hive/signal@2.0.0': + resolution: {integrity: sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==} + engines: {node: '>=20.0.0'} + '@graphql-inspector/audit-command@4.0.3': resolution: {integrity: sha512-cm4EtieIp9PUSDBze+Sn5HHF80jDF9V7sYyXqFa7+Vtw4Jlet98Ig48dFVtoLuFCPtCv2eZ22I8JOkBKL5WgVA==} engines: {node: '>=16.0.0'} @@ -17922,8 +17929,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18075,11 +18082,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18118,7 +18125,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -18295,11 +18301,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -18338,6 +18344,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -18488,7 +18495,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -18664,7 +18671,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -18959,7 +18966,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -20570,6 +20577,8 @@ snapshots: '@graphql-hive/signal@1.0.0': {} + '@graphql-hive/signal@2.0.0': {} + '@graphql-inspector/audit-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': dependencies: '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) @@ -26756,8 +26765,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -29694,13 +29703,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 4.4.1(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -29731,14 +29740,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -29754,7 +29763,7 @@ snapshots: eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -29764,7 +29773,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 From 6a5ad5df439bd15bfa0f0bf17e51ffd314b5d70a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 17:42:06 +0100 Subject: [PATCH 13/20] optional config --- packages/libraries/core/src/client/agent.ts | 97 ++++++++++++--------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 3beac5dad24..55540b07859 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -83,9 +83,12 @@ export interface AgentOptions { */ fetch?: typeof defaultFetch; /** - * Circuit Breaker Configuration + * Circuit Breaker Configuration. + * true -> Use default configuration + * false -> Disable + * object -> use custom configuration see {AgentCircuitBreakerConfiguration} */ - circuitBreaker?: AgentCircuitBreakerConfiguration; + circuitBreaker?: boolean | AgentCircuitBreakerConfiguration; } export function createAgent( @@ -104,7 +107,9 @@ export function createAgent( headers?(): Record; }, ) { - const options: Required> = { + const options: Required> & { + circuitBreaker: null | AgentCircuitBreakerConfiguration; + } = { timeout: 30_000, debug: false, enabled: true, @@ -113,9 +118,15 @@ export function createAgent( sendInterval: 10_000, maxSize: 25, name: 'hive-client', - circuitBreaker: defaultCircuitBreakerConfiguration, version, ...pluginOptions, + circuitBreaker: + pluginOptions.circuitBreaker === false + ? null + : pluginOptions.circuitBreaker === true + ? defaultCircuitBreakerConfiguration + : (pluginOptions.circuitBreaker ?? defaultCircuitBreakerConfiguration), + logger: createHiveLogger(pluginOptions.logger ?? console, '[agent]'), }; @@ -258,47 +269,55 @@ export function createAgent( }); } - /** - * We support Cloudflare, which does not has the `events` module. - * So we lazy load opossum which has `events` as a dependency. - */ - const breakerLogger = createHiveLogger(options.logger, '[circuit breaker]'); - let breaker: CircuitBreakerInterface< Parameters, ReturnType >; + let loadCircuitBreakerPromise: Promise | null = null; + const breakerLogger = createHiveLogger(options.logger, '[circuit breaker]'); - breakerLogger.info('initialize circuit breaker'); - const loadCircuitBreakerPromise = loadCircuitBreaker( - CircuitBreaker => { - breakerLogger.info('started'); - const realBreaker = new CircuitBreaker(sendHTTPCall, { - ...options.circuitBreaker, - autoRenewAbortController: true, - }); + function noopBreaker(): typeof breaker { + return { + getSignal() { + return undefined; + }, + fire: sendHTTPCall, + }; + } - realBreaker.on('open', () => - breakerLogger.error('circuit opened - backend seems unreachable.'), - ); - realBreaker.on('halfOpen', () => - breakerLogger.info('circuit half open - testing backend connectivity'), - ); - realBreaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); - - // @ts-expect-error missing definition in typedefs for `opposum` - breaker = realBreaker; - }, - () => { - breakerLogger.info('circuit breaker not supported on platform'); - breaker = { - getSignal() { - return undefined; - }, - fire: sendHTTPCall, - }; - }, - ); + if (options.circuitBreaker) { + /** + * We support Cloudflare, which does not has the `events` module. + * So we lazy load opossum which has `events` as a dependency. + */ + breakerLogger.info('initialize circuit breaker'); + loadCircuitBreakerPromise = loadCircuitBreaker( + CircuitBreaker => { + breakerLogger.info('started'); + const realBreaker = new CircuitBreaker(sendHTTPCall, { + ...options.circuitBreaker, + autoRenewAbortController: true, + }); + + realBreaker.on('open', () => + breakerLogger.error('circuit opened - backend seems unreachable.'), + ); + realBreaker.on('halfOpen', () => + breakerLogger.info('circuit half open - testing backend connectivity'), + ); + realBreaker.on('close', () => breakerLogger.info('circuit closed - backend recovered ')); + + // @ts-expect-error missing definition in typedefs for `opposum` + breaker = realBreaker; + }, + () => { + breakerLogger.info('circuit breaker not supported on platform'); + breaker = noopBreaker(); + }, + ); + } else { + breaker = noopBreaker(); + } async function sendFromBreaker(...args: Parameters) { if (!breaker) { From cb256970da1a5ad737012c03e186bedfcbc4f955 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Nov 2025 18:38:49 +0100 Subject: [PATCH 14/20] timestamp and action header --- packages/libraries/core/src/client/http-client.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 2c6bd7463f2..9b154c851eb 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -92,6 +92,9 @@ export async function makeFetchCall( let maxTimeout = 2000; let factor = 1.2; + const actionHeader = + config.method === 'POST' ? { 'x-client-action-id': crypto.randomUUID() } : undefined; + if (config.retry !== false) { retries = config.retry?.retries ?? 5; minTimeout = config.retry?.minTimeout ?? 200; @@ -117,6 +120,8 @@ export async function makeFetchCall( body: config.body, headers: { 'x-request-id': requestId, + 'x-client-timestamp': new Date().toISOString(), + ...actionHeader, ...config.headers, }, signal, From 2ace0c68d4ae8fb82815698e9b9e80b0c42fce04 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 14:36:41 +0100 Subject: [PATCH 15/20] remove curcuit breaker timeout option (we already have fetch level retries) --- .changeset/beige-teams-spend.md | 5 ----- .../libraries/core/playground/agent-circuit-breaker.ts | 1 - packages/libraries/core/src/client/agent.ts | 7 +------ 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.changeset/beige-teams-spend.md b/.changeset/beige-teams-spend.md index 3eb0d14275e..5dab7888bdf 100644 --- a/.changeset/beige-teams-spend.md +++ b/.changeset/beige-teams-spend.md @@ -22,11 +22,6 @@ const client = createClient({ * Default: 5 */ volumeThreshold: 5, - /** - * After which time a request should be treated as a timeout in milleseconds - * Default: 5_000 - */ - timeout: 5_000, /** * Percentage of requests failing before the circuit breaker kicks in. * Default: 50 diff --git a/packages/libraries/core/playground/agent-circuit-breaker.ts b/packages/libraries/core/playground/agent-circuit-breaker.ts index 0a89f6e0868..c2661a82326 100644 --- a/packages/libraries/core/playground/agent-circuit-breaker.ts +++ b/packages/libraries/core/playground/agent-circuit-breaker.ts @@ -21,7 +21,6 @@ const agent = createAgent<{}>( }); }, circuitBreaker: { - timeout: 1_000, errorThresholdPercentage: 1, resetTimeout: 10_000, volumeThreshold: 0, diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 55540b07859..d4f66deb290 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -8,11 +8,6 @@ import { createHiveLogger } from './utils.js'; type ReadOnlyResponse = Pick; export type AgentCircuitBreakerConfiguration = { - /** - * After which time a request should be treated as a timeout in milleseconds - * Default: 5_000 - */ - timeout: number; /** * Percentage after what the circuit breaker should kick in. * Default: 50 @@ -31,7 +26,6 @@ export type AgentCircuitBreakerConfiguration = { }; const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { - timeout: 5_000, errorThresholdPercentage: 50, volumeThreshold: 5, resetTimeout: 30_000, @@ -296,6 +290,7 @@ export function createAgent( breakerLogger.info('started'); const realBreaker = new CircuitBreaker(sendHTTPCall, { ...options.circuitBreaker, + timeout: false, autoRenewAbortController: true, }); From 7769de2a6aae6c4eda783d19a36c7a277d9aae8b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 15:07:19 +0100 Subject: [PATCH 16/20] keep it out the logs --- packages/libraries/core/tests/test-utils.ts | 40 ++++++++++++--------- packages/libraries/core/tests/usage.spec.ts | 12 ------- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/packages/libraries/core/tests/test-utils.ts b/packages/libraries/core/tests/test-utils.ts index 8b4639d5231..62ab7300bf0 100644 --- a/packages/libraries/core/tests/test-utils.ts +++ b/packages/libraries/core/tests/test-utils.ts @@ -6,24 +6,30 @@ export function waitFor(ms: number) { /** helper function to get log lines and replace milliseconds with static value. */ function getLogLines(calls: Array>) { - return calls.map(log => { - let msg: string; - if (typeof log[1] === 'string') { - msg = maskRequestId( - log[1] - // Replace milliseconds with static value - .replace(/\(\d{1,4}ms\)/, '(666ms)') - // Replace stack trace line numbers with static value - .replace(/\(node:net:\d+:\d+\)/, '(node:net:666:666)') - .replace(/\(node:dns:\d+:\d+\)/, '(node:dns:666:666)'), - // request UUIDsu - ); - } else { - msg = String(log[1]); - } + return calls + .map(log => { + let msg: string; + if (typeof log[1] === 'string') { + if (log[1].includes('[circuit breaker]')) { + return null; + } - return '[' + log[0] + ']' + ' ' + msg; - }); + msg = maskRequestId( + log[1] + // Replace milliseconds with static value + .replace(/\(\d{1,4}ms\)/, '(666ms)') + // Replace stack trace line numbers with static value + .replace(/\(node:net:\d+:\d+\)/, '(node:net:666:666)') + .replace(/\(node:dns:\d+:\d+\)/, '(node:dns:666:666)'), + // request UUIDsu + ); + } else { + msg = String(log[1]); + } + + return '[' + log[0] + ']' + ' ' + msg; + }) + .filter(line => !!line); } export function createHiveTestingLogger() { diff --git a/packages/libraries/core/tests/usage.spec.ts b/packages/libraries/core/tests/usage.spec.ts index 8ff097b1b60..d44942a6dce 100644 --- a/packages/libraries/core/tests/usage.spec.ts +++ b/packages/libraries/core/tests/usage.spec.ts @@ -165,8 +165,6 @@ test('should send data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -277,8 +275,6 @@ test('should send data to Hive (deprecated endpoint)', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -370,8 +366,6 @@ test('should not leak the exception', { retry: 3 }, async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://404.localhost.noop (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) [ERR] [hive][usage][agent] Error: getaddrinfo ENOTFOUND 404.localhost.noop @@ -542,9 +536,7 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker [INF] [hive][usage][agent] Disposing - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 2) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) succeeded with status 200 (666ms). @@ -648,8 +640,6 @@ test('should not send excluded operation name data to Hive', async () => { http.done(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Disposing [INF] [hive][usage][agent] Sending report (queue 2) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) @@ -751,8 +741,6 @@ test('retry on non-200', async () => { await hive.dispose(); expect(logger.getLogs()).toMatchInlineSnapshot(` - [INF] [hive][usage][agent][circuit breaker] initialize circuit breaker - [INF] [hive][usage][agent][circuit breaker] started [INF] [hive][usage][agent] Sending report (queue 1) [INF] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) Attempt (1/2) [ERR] [hive][usage][agent] POST http://localhost/200 (x-request-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) failed with status 500 (666ms): No no no From f93601bf52dfb5b0bfaa0d0dc2fe3faf1835447a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 15:09:31 +0100 Subject: [PATCH 17/20] tweak default configuration --- packages/libraries/core/src/client/agent.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index d4f66deb290..bd404e89024 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -27,7 +27,7 @@ export type AgentCircuitBreakerConfiguration = { const defaultCircuitBreakerConfiguration: AgentCircuitBreakerConfiguration = { errorThresholdPercentage: 50, - volumeThreshold: 5, + volumeThreshold: 10, resetTimeout: 30_000, }; @@ -115,12 +115,11 @@ export function createAgent( version, ...pluginOptions, circuitBreaker: - pluginOptions.circuitBreaker === false - ? null - : pluginOptions.circuitBreaker === true - ? defaultCircuitBreakerConfiguration - : (pluginOptions.circuitBreaker ?? defaultCircuitBreakerConfiguration), - + pluginOptions.circuitBreaker == null || pluginOptions.circuitBreaker === true + ? defaultCircuitBreakerConfiguration + : pluginOptions.circuitBreaker === false + ? null + : pluginOptions.circuitBreaker, logger: createHiveLogger(pluginOptions.logger ?? console, '[agent]'), }; From b6ef3c008541068c6cf4a824f8f75199a8a9b1c0 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 15:18:48 +0100 Subject: [PATCH 18/20] organize --- packages/libraries/core/src/client/agent.ts | 21 +-------------------- packages/libraries/core/src/client/utils.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index bd404e89024..163d8fde80f 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -1,9 +1,8 @@ -import type CircuitBreaker from 'opossum'; import { fetch as defaultFetch } from '@whatwg-node/fetch'; import { version } from '../version.js'; import { http } from './http-client.js'; import type { Logger } from './types.js'; -import { createHiveLogger } from './utils.js'; +import { CircuitBreakerInterface, createHiveLogger, loadCircuitBreaker } from './utils.js'; type ReadOnlyResponse = Pick; @@ -342,21 +341,3 @@ export function createAgent( dispose, }; } - -type CircuitBreakerInterface = { - fire(...args: TI): TR; - getSignal(): AbortSignal | undefined; -}; - -async function loadCircuitBreaker( - success: (breaker: typeof CircuitBreaker) => void, - error: () => void, -): Promise { - const packageName = 'opossum'; - try { - const module = await import(packageName); - success(module.default); - } catch (err) { - error(); - } -} diff --git a/packages/libraries/core/src/client/utils.ts b/packages/libraries/core/src/client/utils.ts index d67e31b6f06..a1d8ffe4ca7 100644 --- a/packages/libraries/core/src/client/utils.ts +++ b/packages/libraries/core/src/client/utils.ts @@ -1,3 +1,4 @@ +import type CircuitBreaker from 'opossum'; import { crypto, TextEncoder } from '@whatwg-node/fetch'; import { hiveClientSymbol } from './client.js'; import type { HiveClient, HivePluginOptions, Logger } from './types.js'; @@ -226,3 +227,21 @@ export function isLegacyAccessToken(accessToken: string): boolean { return false; } + +export async function loadCircuitBreaker( + success: (breaker: typeof CircuitBreaker) => void, + error: () => void, +): Promise { + const packageName = 'opossum'; + try { + const module = await import(packageName); + success(module.default); + } catch (err) { + error(); + } +} + +export type CircuitBreakerInterface = { + fire(...args: TI): TR; + getSignal(): AbortSignal | undefined; +}; From e7f5300ba77688aeafca00d042f8ca4d7bac5f99 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 15:32:00 +0100 Subject: [PATCH 19/20] remove client timestamp --- packages/libraries/core/src/client/http-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/libraries/core/src/client/http-client.ts b/packages/libraries/core/src/client/http-client.ts index 9b154c851eb..503bdaa5d07 100644 --- a/packages/libraries/core/src/client/http-client.ts +++ b/packages/libraries/core/src/client/http-client.ts @@ -120,7 +120,6 @@ export async function makeFetchCall( body: config.body, headers: { 'x-request-id': requestId, - 'x-client-timestamp': new Date().toISOString(), ...actionHeader, ...config.headers, }, From 262e3b9d8c6492352b8d3b6f8b335b7c65241e5a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Nov 2025 16:17:03 +0100 Subject: [PATCH 20/20] cleanup --- packages/libraries/core/src/client/agent.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/libraries/core/src/client/agent.ts b/packages/libraries/core/src/client/agent.ts index 163d8fde80f..459939289e3 100644 --- a/packages/libraries/core/src/client/agent.ts +++ b/packages/libraries/core/src/client/agent.ts @@ -320,15 +320,9 @@ export function createAgent( try { return await breaker.fire(...args); } catch (err: unknown) { - if (err instanceof Error && 'code' in err) { - if (err.code === 'EOPENBREAKER') { - breakerLogger.info('circuit open - sending report skipped'); - return null; - } - if (err.code === 'ETIMEDOUT') { - breakerLogger.info('circuit open - sending report aborted - timed out'); - return null; - } + if (err instanceof Error && 'code' in err && err.code === 'EOPENBREAKER') { + breakerLogger.info('circuit open - sending report skipped'); + return null; } throw err;