From f557866c09b97f907c50d35bdb2a686aedfd4698 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 17:41:10 -0700 Subject: [PATCH 01/62] Create tables for tracking coordinate errors; add coordinate execution count column to operations table --- .../018-operation-errors.ts | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 packages/migrations/src/clickhouse-actions/018-operation-errors.ts diff --git a/packages/migrations/src/clickhouse-actions/018-operation-errors.ts b/packages/migrations/src/clickhouse-actions/018-operation-errors.ts new file mode 100644 index 00000000000..2dabb14b977 --- /dev/null +++ b/packages/migrations/src/clickhouse-actions/018-operation-errors.ts @@ -0,0 +1,344 @@ +import type { Action } from '../clickhouse'; + +/** + * !IMPORTANT!: All materialized views use "TO" syntax, which is considered best + * practice for clickhouse because it decouples the data storage from the view logic, + * which makes it easier to manage migrations and direct inserts. + */ + +export const action: Action = async exec => { + /** + * Add a column to hold the real executed field counts. This differs from existing counts, which + * are for the number of times the operation was executed and (in the operation_collection table) + * whether or not the coordinate is included in an operation's body. + */ + await exec(` + ALTER TABLE default.operations + -- Counts the coordinates actually called during execution. This is necessary so that + -- field availability is not based on operation count, which would skew the availability + -- to show a higher error rate (e.g. if an array has one object that errored, then the + -- availability) should be (1-N)/N, not 0% + ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) DEFAULT map() + ; + `); + + /** + * This is a source table that gets projected to various data points. Therefore this table's + * TTL does not need to be dictated by the customer data lifespan. + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.operation_errors + ( + target UUID + + -- hash stores a md5 of the body, coordinate, and operation name + -- stores as raw binary which requires hex() on returned md5 and unhex() on input + -- this saves space by allowing a FixedString(16) compared to FixedString(32) + , hash FixedString(16) CODEC(ZSTD(1)) + + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + + -- Expiration is based on plan retention, but this needs set on insert, but + -- the expiration will always exceed this table's TTL. This expires_at is + -- intended to be for the materialized views that are generated from this table + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , errors Array(Tuple(code LowCardinality(String), path String)) CODEC(ZSTD(1)) + ) + ENGINE = MergeTree + PARTITION BY toStartOfHour(timestamp) + PRIMARY KEY (target, hash, timestamp) + ORDER BY (target, hash, timestamp, expires_at) + TTL timestamp + INTERVAL 3 HOUR + SETTINGS index_granularity = 8192 + ; + `); + + /** Minutely metric table */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_errors_minutely + ( + target UUID + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMMDD(timestamp) + PRIMARY KEY (target, coordinate, code, timestamp, hash) + ORDER BY (target, coordinate, code, timestamp, hash, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_errors_minutely + TO default.coordinate_errors_minutely + AS + SELECT + target + , hash + , toStartOfMinute(timestamp) AS timestamp + , toStartOfMinute(expires_at) AS expires_at + , error.path as coordinate + , error.code as code + , count() as total_errors + FROM default.operation_errors + ARRAY JOIN errors as error + GROUP BY + target + , error.path + , timestamp + , hash + , error.code + , expires_at + ; + `); + + /** + * Cascading hourly metric table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_errors_hourly + ( + target UUID + + -- hash stores a md5 of the body, coordinate, and operation name + -- stores as raw binary which requires hex() on returned md5 and unhex() on input + -- this saves space by allowing a FixedString(16) compared to FixedString(32) + , hash FixedString(16) CODEC(LZ4) + + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMM(timestamp) + PRIMARY KEY (target, coordinate, code, timestamp, hash) + ORDER BY (target, coordinate, code, timestamp, hash, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS mv_coordinate_errors_hourly + TO default.coordinate_errors_hourly + AS + SELECT + target + , hash + , toStartOfHour(timestamp) AS timestamp + -- Expiration is based on the subscription plan, so it can't be a static interval. + , toStartOfHour(expires_at) AS expires_at + , coordinate + , code + , sum(total_errors) as total_errors + FROM default.coordinate_errors_minutely + GROUP BY + target + , coordinate + , timestamp + , hash + , code + -- Expiration must be a separate group in case the users subscription changes. + -- When selected, the data will be summed by the ORDER BY. Subscription changes + -- are rare so any performance implication is minimal. + , expires_at + ; + `); + + /** + * Use cascading aggregation to create daily + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_errors_daily + ( + target UUID + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMM(timestamp) + PRIMARY KEY (target, coordinate, code, timestamp, hash) + ORDER BY (target, coordinate, code, timestamp, hash, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_errors_daily + TO default.coordinate_errors_daily + AS + SELECT + target + , hash + , toStartOfDay(timestamp) AS timestamp + , toStartOfDay(expires_at) AS expires_at + , coordinate + , code + , sum(total_errors) as total_errors + FROM default.coordinate_errors_hourly + GROUP BY + target + , coordinate + , timestamp + , hash + , code + , expires_at + ; + `); + + /** + * To support querying by coordinate (for schema-wide metrics) AND by hash (for operation specific metrics), + * create another set of tables that store this same data in a different order... + */ + + /** + * Minutely Hash Table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.operation_errors_minutely + ( + target LowCardinality(UUID) + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMMDD(timestamp) + -- Hash is placed immediately after target to optimize hash-specific queries + PRIMARY KEY (target, hash, coordinate, code, timestamp) + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_minutely + TO default.operation_errors_minutely + AS + SELECT + target + , hash + , toStartOfMinute(timestamp) AS timestamp + , toStartOfMinute(expires_at) AS expires_at + , error.path as coordinate + , error.code as code + , count() as total_errors + FROM default.operation_errors + ARRAY JOIN errors as error + GROUP BY + target + , hash + , error.path + , error.code + , timestamp + , expires_at + ; + `); + + /** + * Hourly Hash Table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.operation_errors_hourly + ( + target LowCardinality(UUID) + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMM(timestamp) + PRIMARY KEY (target, hash, coordinate, code, timestamp) + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_hourly + TO default.operation_errors_hourly + AS + SELECT + target + , hash + , toStartOfHour(timestamp) AS timestamp + , toStartOfHour(expires_at) AS expires_at + , coordinate + , code + , sum(total_errors) as total_errors + FROM default.operation_errors_minutely + GROUP BY + target + , hash + , coordinate + , code + , timestamp + , expires_at + ; + `); + + /** + * Daily Hash Table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.operation_errors_daily + ( + target LowCardinality(UUID) + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , code LowCardinality(String) CODEC(ZSTD(1)) + , total_errors UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMM(timestamp) + PRIMARY KEY (target, hash, coordinate, code, timestamp) + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_daily + TO default.operation_errors_daily + AS + SELECT + target + , hash + , toStartOfDay(timestamp) AS timestamp + , toStartOfDay(expires_at) AS expires_at + , coordinate + , code + , sum(total_errors) as total_errors + FROM default.operation_errors_hourly + GROUP BY + target + , hash + , coordinate + , code + , timestamp + , expires_at + ; + `); +}; From 3ade7e90d5960f285b000b21f26daa541050aaae Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 17:43:04 -0700 Subject: [PATCH 02/62] format --- .../migrations/src/clickhouse-actions/018-operation-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/clickhouse-actions/018-operation-errors.ts b/packages/migrations/src/clickhouse-actions/018-operation-errors.ts index 2dabb14b977..9389eba7236 100644 --- a/packages/migrations/src/clickhouse-actions/018-operation-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-operation-errors.ts @@ -125,7 +125,7 @@ export const action: Action = async exec => { SETTINGS index_granularity = 8192 ; `); - exec(` + await exec(` CREATE MATERIALIZED VIEW IF NOT EXISTS mv_coordinate_errors_hourly TO default.coordinate_errors_hourly AS From 9836551181b3982de91455b2a0cb3170766f9c0d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 17:44:12 -0700 Subject: [PATCH 03/62] rename and add import --- .../{018-operation-errors.ts => 018-usage-coordinate-errors.ts} | 0 packages/migrations/src/clickhouse.ts | 1 + 2 files changed, 1 insertion(+) rename packages/migrations/src/clickhouse-actions/{018-operation-errors.ts => 018-usage-coordinate-errors.ts} (100%) diff --git a/packages/migrations/src/clickhouse-actions/018-operation-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts similarity index 100% rename from packages/migrations/src/clickhouse-actions/018-operation-errors.ts rename to packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts diff --git a/packages/migrations/src/clickhouse.ts b/packages/migrations/src/clickhouse.ts index 3751639e295..bbdea4a006d 100644 --- a/packages/migrations/src/clickhouse.ts +++ b/packages/migrations/src/clickhouse.ts @@ -178,6 +178,7 @@ export async function migrateClickHouse( import('./clickhouse-actions/015-otel-trace'), import('./clickhouse-actions/016-subgraph-otel-traces-cleanup'), import('./clickhouse-actions/017-affected-app-deployments-performance'), + import('./clickhouse-actions/018-usage-coordinate-errors'), ]); async function actionRunner(action: Action, index: number) { From 86ba95763d2fc6b7327f414cfed3303af414cd5e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:26:24 -0700 Subject: [PATCH 04/62] move coordinate counts to its own migration file --- .../018-usage-coordinate-errors.ts | 30 +--- .../019-usage-coordinate-counts.ts | 162 ++++++++++++++++++ 2 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 9389eba7236..c9622572cc0 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -7,21 +7,6 @@ import type { Action } from '../clickhouse'; */ export const action: Action = async exec => { - /** - * Add a column to hold the real executed field counts. This differs from existing counts, which - * are for the number of times the operation was executed and (in the operation_collection table) - * whether or not the coordinate is included in an operation's body. - */ - await exec(` - ALTER TABLE default.operations - -- Counts the coordinates actually called during execution. This is necessary so that - -- field availability is not based on operation count, which would skew the availability - -- to show a higher error rate (e.g. if an array has one object that errored, then the - -- availability) should be (1-N)/N, not 0% - ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) DEFAULT map() - ; - `); - /** * This is a source table that gets projected to various data points. Therefore this table's * TTL does not need to be dictated by the customer data lifespan. @@ -66,10 +51,11 @@ export const action: Action = async exec => { , total_errors UInt32 CODEC(T64, ZSTD(1)) ) ENGINE = SummingMergeTree - PARTITION BY toYYYYMMDD(timestamp) + PARTITION BY toStartOfHour(timestamp) PRIMARY KEY (target, coordinate, code, timestamp, hash) ORDER BY (target, coordinate, code, timestamp, hash, expires_at) - TTL expires_at + -- only store for 24hr because after that, the hourly or daily table will be used + TTL least(timestamp + toIntervalHour(24), expires_at) SETTINGS index_granularity = 8192 ; `); @@ -118,10 +104,11 @@ export const action: Action = async exec => { , total_errors UInt32 CODEC(T64, ZSTD(1)) ) ENGINE = SummingMergeTree - PARTITION BY toYYYYMM(timestamp) + PARTITION BY toYYYYMMDD(timestamp) PRIMARY KEY (target, coordinate, code, timestamp, hash) ORDER BY (target, coordinate, code, timestamp, hash, expires_at) - TTL expires_at + -- keep for a maximum of 30 days because after that the relative time range will only use the daily calculations + TTL least(timestamp + toIntervalDay(30), expires_at) SETTINGS index_granularity = 8192 ; `); @@ -218,11 +205,12 @@ export const action: Action = async exec => { , total_errors UInt32 CODEC(T64, ZSTD(1)) ) ENGINE = SummingMergeTree - PARTITION BY toYYYYMMDD(timestamp) + PARTITION BY toStartOfHour(timestamp) -- Hash is placed immediately after target to optimize hash-specific queries PRIMARY KEY (target, hash, coordinate, code, timestamp) ORDER BY (target, hash, coordinate, code, timestamp, expires_at) - TTL expires_at + -- only store for 24hr because after that, the hourly or daily table will be used + TTL least(timestamp + toIntervalHour(24), expires_at) SETTINGS index_granularity = 8192; `); diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts new file mode 100644 index 00000000000..824db46b686 --- /dev/null +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -0,0 +1,162 @@ +import type { Action } from '../clickhouse'; + +export const action: Action = async exec => { + /** + * Add a column to hold the real executed field counts. This differs from existing counts, which + * are for the number of times the operation was executed and (in the operation_collection table) + * whether or not the coordinate is included in an operation's body. + */ + await exec(` + ALTER TABLE default.operations + -- Counts the coordinates actually called during execution. This is necessary so that + -- field availability is not based on operation count, which would skew the availability + -- to show a higher error rate (e.g. if an array has one object that errored, then the + -- availability) should be (1-N)/N, not 0% + ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) DEFAULT map() + ; + `); + + /** + * This replaces the counts for display purpose (explorer page) by coordinate. The existing counts + * represent the number of operations calls that used the coordinate, rather than the number of times + * the coordinate was executed/resolved. + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_counts_minutely + ( + target UUID + + -- hash stores a md5 of the body, coordinate, and operation name + -- stores as raw binary which requires hex() on returned md5 and unhex() on input + -- this saves space by allowing a FixedString(16) compared to FixedString(32) + , hash FixedString(16) CODEC(ZSTD(1)) + + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + + -- Expiration is based on plan retention, but this needs set on insert, but + -- the expiration will always exceed this table's TTL. This expires_at is + -- intended to be for the materialized views that are generated from this table + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , total UInt32 CODEC(T64, ZSTD(1)) + ) + -- The engine is kept as a merge tree to + ENGINE = SummingMergeTree + PARTITION BY toStartOfHour(timestamp) + PRIMARY KEY (target, coordinate, hash, timestamp) + ORDER BY (target, coordinate, hash, timestamp, expires_at) + -- only store for 24hr because after that, the hourly or daily table will be used + TTL least(timestamp + toIntervalHour(24), expires_at) + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_counts_minutely + TO default.coordinate_counts_minutely + AS + SELECT + target + , hash + , toStartOfMinute(timestamp) AS timestamp + , toStartOfMinute(expires_at) AS expires_at + , total.0 as coordinate + , sum(total.1) as total + FROM default.operations + ARRAY JOIN coordinate_totals as total + GROUP BY + target + , total.0 + , hash + , timestamp + -- expires at is important in the group by to avoid overriding metrics early if the plan changes + , expires_at + ; + `); + + /** + * Cascading hourly coordinate counts table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_counts_hourly + ( + target UUID + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , total UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMMDD(timestamp) + PRIMARY KEY (target, coordinate, hash, timestamp) + ORDER BY (target, coordinate, hash, timestamp, expires_at) + TTL least(timestamp + toIntervalDay(30), expires_at) + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_counts_hourly + TO default.coordinate_counts_hourly + AS + SELECT + target + , hash + , toStartOfHour(timestamp) AS timestamp + , toStartOfHour(expires_at) AS expires_at + , coordinate + , sum(total) as total + FROM default.coordinate_counts_minutely + GROUP BY + target + , coordinate + , hash + , timestamp + , expires_at + ; + `); + + /** + * Cascading daily coordinate counts table + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.coordinate_counts_daily + ( + target UUID + , hash FixedString(16) CODEC(LZ4) + , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) + , coordinate String CODEC(ZSTD(1)) + , total UInt32 CODEC(T64, ZSTD(1)) + ) + ENGINE = SummingMergeTree + PARTITION BY toYYYYMM(timestamp) + PRIMARY KEY (target, coordinate, hash, timestamp) + ORDER BY (target, coordinate, hash, timestamp, expires_at) + TTL expires_at + SETTINGS index_granularity = 8192 + ; + `); + + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_counts_daily + TO default.coordinate_counts_daily + AS + SELECT + target + , hash + , toStartOfDay(timestamp) AS timestamp + , toStartOfDay(expires_at) AS expires_at + , coordinate + , sum(total) as total + FROM default.coordinate_counts_hourly + GROUP BY + target + , coordinate + , hash + , timestamp + , expires_at + ; + `); +}; From 8f27ab426335aaf05263f3cceccf9409c12edaeb Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:32:38 -0700 Subject: [PATCH 05/62] Add import --- packages/migrations/src/clickhouse.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/migrations/src/clickhouse.ts b/packages/migrations/src/clickhouse.ts index bbdea4a006d..a4a683b40c4 100644 --- a/packages/migrations/src/clickhouse.ts +++ b/packages/migrations/src/clickhouse.ts @@ -179,6 +179,7 @@ export async function migrateClickHouse( import('./clickhouse-actions/016-subgraph-otel-traces-cleanup'), import('./clickhouse-actions/017-affected-app-deployments-performance'), import('./clickhouse-actions/018-usage-coordinate-errors'), + import('./clickhouse-actions/019-usage-coordinate-counts'), ]); async function actionRunner(action: Action, index: number) { From cfce6e11e38205829746e3209d0d0f7a9b834142 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:56:49 -0700 Subject: [PATCH 06/62] Update packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index c9622572cc0..1b023ebb887 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -43,7 +43,7 @@ export const action: Action = async exec => { CREATE TABLE IF NOT EXISTS default.coordinate_errors_minutely ( target UUID - , hash FixedString(16) CODEC(LZ4) + , hash FixedString(16) CODEC(ZSTD(1)) , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , coordinate String CODEC(ZSTD(1)) From 52d69dd9a19fad0826758e2b03b10ba4c7d7b3e2 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:56:59 -0700 Subject: [PATCH 07/62] Update packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 1b023ebb887..4d8457de493 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -113,7 +113,7 @@ export const action: Action = async exec => { ; `); await exec(` - CREATE MATERIALIZED VIEW IF NOT EXISTS mv_coordinate_errors_hourly + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_coordinate_errors_hourly TO default.coordinate_errors_hourly AS SELECT From 18e3d2bfefb3504726c8dadf4c1369fb161a13d3 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:57:18 -0700 Subject: [PATCH 08/62] Update packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 4d8457de493..66b8daf389c 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -196,7 +196,7 @@ export const action: Action = async exec => { await exec(` CREATE TABLE IF NOT EXISTS default.operation_errors_minutely ( - target LowCardinality(UUID) + target UUID , hash FixedString(16) CODEC(LZ4) , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) From e8190cdfe00f5886c1523d4450cd570a93562330 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 May 2026 19:59:10 -0700 Subject: [PATCH 09/62] Update packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 66b8daf389c..10198c9f60a 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -27,7 +27,7 @@ export const action: Action = async exec => { -- the expiration will always exceed this table's TTL. This expires_at is -- intended to be for the materialized views that are generated from this table , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , errors Array(Tuple(code LowCardinality(String), path String)) CODEC(ZSTD(1)) + , errors Array(Tuple(code String, path String)) CODEC(ZSTD(1)) ) ENGINE = MergeTree PARTITION BY toStartOfHour(timestamp) From d32d5f1e507133c54da66385784dc70aebab3b9d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 May 2026 12:07:41 -0700 Subject: [PATCH 10/62] Remove lowcardinality from uuid --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 10198c9f60a..299270dd38f 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -244,7 +244,7 @@ export const action: Action = async exec => { await exec(` CREATE TABLE IF NOT EXISTS default.operation_errors_hourly ( - target LowCardinality(UUID) + target UUID , hash FixedString(16) CODEC(LZ4) , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) @@ -290,7 +290,7 @@ export const action: Action = async exec => { await exec(` CREATE TABLE IF NOT EXISTS default.operation_errors_daily ( - target LowCardinality(UUID) + target UUID , hash FixedString(16) CODEC(LZ4) , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) From 9051d9e906c19e7d31d3c61adbf3c405ba7b7c69 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 May 2026 13:41:13 -0700 Subject: [PATCH 11/62] Fix 019-usage-coordinate-counts migration --- .../019-usage-coordinate-counts.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index 824db46b686..481e38e450d 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -12,7 +12,7 @@ export const action: Action = async exec => { -- field availability is not based on operation count, which would skew the availability -- to show a higher error rate (e.g. if an array has one object that errored, then the -- availability) should be (1-N)/N, not 0% - ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) DEFAULT map() + ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) ; `); @@ -60,13 +60,14 @@ export const action: Action = async exec => { , hash , toStartOfMinute(timestamp) AS timestamp , toStartOfMinute(expires_at) AS expires_at - , total.0 as coordinate - , sum(total.1) as total + , coord_total.1 as coordinate + -- Cast the UInt64 sum back down to UInt32 to match the target table + , CAST(sum(coord_total.2) AS UInt32) as total FROM default.operations - ARRAY JOIN coordinate_totals as total + ARRAY JOIN coordinate_totals as coord_total GROUP BY target - , total.0 + , coord_total.1 , hash , timestamp -- expires at is important in the group by to avoid overriding metrics early if the plan changes @@ -106,7 +107,7 @@ export const action: Action = async exec => { , toStartOfHour(timestamp) AS timestamp , toStartOfHour(expires_at) AS expires_at , coordinate - , sum(total) as total + , CAST(sum(total) AS UInt32) as total FROM default.coordinate_counts_minutely GROUP BY target @@ -149,7 +150,7 @@ export const action: Action = async exec => { , toStartOfDay(timestamp) AS timestamp , toStartOfDay(expires_at) AS expires_at , coordinate - , sum(total) as total + , CAST(sum(total) AS UInt32) as total FROM default.coordinate_counts_hourly GROUP BY target From 6ca66ab185010cbfcc0cabc4e4be2c5d7adc55ec Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 May 2026 14:40:49 -0700 Subject: [PATCH 12/62] Use a projection instead of a second set of tables; fix syntax errors --- .../018-usage-coordinate-errors.ts | 182 +++--------------- .../019-usage-coordinate-counts.ts | 14 +- 2 files changed, 36 insertions(+), 160 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 299270dd38f..621a14ead16 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -49,6 +49,15 @@ export const action: Action = async exec => { , coordinate String CODEC(ZSTD(1)) , code LowCardinality(String) CODEC(ZSTD(1)) , total_errors UInt32 CODEC(T64, ZSTD(1)) + + -- To support querying by coordinate (for schema-wide metrics) + -- AND by hash (for operation specific metrics), create projection + -- that stores this same data in a different order. + -- And use explicit selects to make any future migrations easier + , PROJECTION hash_order ( + SELECT target, hash, coordinate, code, timestamp, expires_at, total_errors + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + ) ) ENGINE = SummingMergeTree PARTITION BY toStartOfHour(timestamp) @@ -71,16 +80,16 @@ export const action: Action = async exec => { , toStartOfMinute(expires_at) AS expires_at , error.path as coordinate , error.code as code - , count() as total_errors + , CAST(count() AS UInt32) as total_errors FROM default.operation_errors ARRAY JOIN errors as error GROUP BY target , error.path - , timestamp + , toStartOfMinute(timestamp) , hash , error.code - , expires_at + , toStartOfMinute(expires_at) ; `); @@ -102,6 +111,11 @@ export const action: Action = async exec => { , coordinate String CODEC(ZSTD(1)) , code LowCardinality(String) CODEC(ZSTD(1)) , total_errors UInt32 CODEC(T64, ZSTD(1)) + + , PROJECTION hash_order ( + SELECT target, hash, coordinate, code, timestamp, expires_at, total_errors + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + ) ) ENGINE = SummingMergeTree PARTITION BY toYYYYMMDD(timestamp) @@ -124,18 +138,18 @@ export const action: Action = async exec => { , toStartOfHour(expires_at) AS expires_at , coordinate , code - , sum(total_errors) as total_errors + , CAST(sum(total_errors) AS UInt32) as total_errors FROM default.coordinate_errors_minutely GROUP BY target , coordinate - , timestamp + , toStartOfHour(timestamp) , hash , code -- Expiration must be a separate group in case the users subscription changes. -- When selected, the data will be summed by the ORDER BY. Subscription changes -- are rare so any performance implication is minimal. - , expires_at + , toStartOfHour(expires_at) ; `); @@ -152,6 +166,11 @@ export const action: Action = async exec => { , coordinate String CODEC(ZSTD(1)) , code LowCardinality(String) CODEC(ZSTD(1)) , total_errors UInt32 CODEC(T64, ZSTD(1)) + + , PROJECTION hash_order ( + SELECT target, hash, coordinate, code, timestamp, expires_at, total_errors + ORDER BY (target, hash, coordinate, code, timestamp, expires_at) + ) ) ENGINE = SummingMergeTree PARTITION BY toYYYYMM(timestamp) @@ -173,160 +192,15 @@ export const action: Action = async exec => { , toStartOfDay(expires_at) AS expires_at , coordinate , code - , sum(total_errors) as total_errors + , CAST(sum(total_errors) AS UInt32) as total_errors FROM default.coordinate_errors_hourly GROUP BY target , coordinate - , timestamp - , hash - , code - , expires_at - ; - `); - - /** - * To support querying by coordinate (for schema-wide metrics) AND by hash (for operation specific metrics), - * create another set of tables that store this same data in a different order... - */ - - /** - * Minutely Hash Table - */ - await exec(` - CREATE TABLE IF NOT EXISTS default.operation_errors_minutely - ( - target UUID - , hash FixedString(16) CODEC(LZ4) - , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , coordinate String CODEC(ZSTD(1)) - , code LowCardinality(String) CODEC(ZSTD(1)) - , total_errors UInt32 CODEC(T64, ZSTD(1)) - ) - ENGINE = SummingMergeTree - PARTITION BY toStartOfHour(timestamp) - -- Hash is placed immediately after target to optimize hash-specific queries - PRIMARY KEY (target, hash, coordinate, code, timestamp) - ORDER BY (target, hash, coordinate, code, timestamp, expires_at) - -- only store for 24hr because after that, the hourly or daily table will be used - TTL least(timestamp + toIntervalHour(24), expires_at) - SETTINGS index_granularity = 8192; - `); - - await exec(` - CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_minutely - TO default.operation_errors_minutely - AS - SELECT - target + , toStartOfDay(timestamp) , hash - , toStartOfMinute(timestamp) AS timestamp - , toStartOfMinute(expires_at) AS expires_at - , error.path as coordinate - , error.code as code - , count() as total_errors - FROM default.operation_errors - ARRAY JOIN errors as error - GROUP BY - target - , hash - , error.path - , error.code - , timestamp - , expires_at - ; - `); - - /** - * Hourly Hash Table - */ - await exec(` - CREATE TABLE IF NOT EXISTS default.operation_errors_hourly - ( - target UUID - , hash FixedString(16) CODEC(LZ4) - , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , coordinate String CODEC(ZSTD(1)) - , code LowCardinality(String) CODEC(ZSTD(1)) - , total_errors UInt32 CODEC(T64, ZSTD(1)) - ) - ENGINE = SummingMergeTree - PARTITION BY toYYYYMM(timestamp) - PRIMARY KEY (target, hash, coordinate, code, timestamp) - ORDER BY (target, hash, coordinate, code, timestamp, expires_at) - TTL expires_at - SETTINGS index_granularity = 8192 - ; - `); - - await exec(` - CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_hourly - TO default.operation_errors_hourly - AS - SELECT - target - , hash - , toStartOfHour(timestamp) AS timestamp - , toStartOfHour(expires_at) AS expires_at - , coordinate - , code - , sum(total_errors) as total_errors - FROM default.operation_errors_minutely - GROUP BY - target - , hash - , coordinate - , code - , timestamp - , expires_at - ; - `); - - /** - * Daily Hash Table - */ - await exec(` - CREATE TABLE IF NOT EXISTS default.operation_errors_daily - ( - target UUID - , hash FixedString(16) CODEC(LZ4) - , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) - , coordinate String CODEC(ZSTD(1)) - , code LowCardinality(String) CODEC(ZSTD(1)) - , total_errors UInt32 CODEC(T64, ZSTD(1)) - ) - ENGINE = SummingMergeTree - PARTITION BY toYYYYMM(timestamp) - PRIMARY KEY (target, hash, coordinate, code, timestamp) - ORDER BY (target, hash, coordinate, code, timestamp, expires_at) - TTL expires_at - SETTINGS index_granularity = 8192 - ; - `); - - await exec(` - CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_operation_errors_daily - TO default.operation_errors_daily - AS - SELECT - target - , hash - , toStartOfDay(timestamp) AS timestamp - , toStartOfDay(expires_at) AS expires_at - , coordinate - , code - , sum(total_errors) as total_errors - FROM default.operation_errors_hourly - GROUP BY - target - , hash - , coordinate , code - , timestamp - , expires_at + , toStartOfDay(expires_at) ; `); }; diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index 481e38e450d..ecbe6285955 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -69,9 +69,9 @@ export const action: Action = async exec => { target , coord_total.1 , hash - , timestamp + , toStartOfMinute(timestamp) -- expires at is important in the group by to avoid overriding metrics early if the plan changes - , expires_at + , toStartOfMinute(expires_at) ; `); @@ -113,8 +113,8 @@ export const action: Action = async exec => { target , coordinate , hash - , timestamp - , expires_at + , toStartOfHour(timestamp) + , toStartOfHour(expires_at) ; `); @@ -156,8 +156,10 @@ export const action: Action = async exec => { target , coordinate , hash - , timestamp - , expires_at + -- group by prioritizes the original values over aliases. Call the same time modifiers here to correctly group data + -- in order to be performant + , toStartOfDay(timestamp) + , toStartOfDay(expires_at) ; `); }; From ab312dee49fbfb6bda50c14f9d733c56918be837 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 May 2026 16:15:02 -0700 Subject: [PATCH 13/62] Add projection rebuild mode --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 621a14ead16..59ea232a7cd 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -65,7 +65,7 @@ export const action: Action = async exec => { ORDER BY (target, coordinate, code, timestamp, hash, expires_at) -- only store for 24hr because after that, the hourly or daily table will be used TTL least(timestamp + toIntervalHour(24), expires_at) - SETTINGS index_granularity = 8192 + SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' ; `); @@ -123,7 +123,7 @@ export const action: Action = async exec => { ORDER BY (target, coordinate, code, timestamp, hash, expires_at) -- keep for a maximum of 30 days because after that the relative time range will only use the daily calculations TTL least(timestamp + toIntervalDay(30), expires_at) - SETTINGS index_granularity = 8192 + SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' ; `); await exec(` @@ -177,7 +177,7 @@ export const action: Action = async exec => { PRIMARY KEY (target, coordinate, code, timestamp, hash) ORDER BY (target, coordinate, code, timestamp, hash, expires_at) TTL expires_at - SETTINGS index_granularity = 8192 + SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' ; `); From 9032ee0f7460f0151097f9a6fffd3140e267dbd3 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 May 2026 17:14:19 -0700 Subject: [PATCH 14/62] Fix migration syntax --- .../018-usage-coordinate-errors.ts | 16 ++++++++-------- .../019-usage-coordinate-counts.ts | 16 +++++++--------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 59ea232a7cd..9724be5a336 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -85,11 +85,11 @@ export const action: Action = async exec => { ARRAY JOIN errors as error GROUP BY target - , error.path - , toStartOfMinute(timestamp) + , coordinate + , timestamp , hash - , error.code - , toStartOfMinute(expires_at) + , code + , expires_at ; `); @@ -143,13 +143,13 @@ export const action: Action = async exec => { GROUP BY target , coordinate - , toStartOfHour(timestamp) + , timestamp , hash , code -- Expiration must be a separate group in case the users subscription changes. -- When selected, the data will be summed by the ORDER BY. Subscription changes -- are rare so any performance implication is minimal. - , toStartOfHour(expires_at) + , expires_at ; `); @@ -197,10 +197,10 @@ export const action: Action = async exec => { GROUP BY target , coordinate - , toStartOfDay(timestamp) + , timestamp , hash , code - , toStartOfDay(expires_at) + , expires_at ; `); }; diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index ecbe6285955..7084568208b 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -67,11 +67,11 @@ export const action: Action = async exec => { ARRAY JOIN coordinate_totals as coord_total GROUP BY target - , coord_total.1 + , coordinate , hash - , toStartOfMinute(timestamp) + , timestamp -- expires at is important in the group by to avoid overriding metrics early if the plan changes - , toStartOfMinute(expires_at) + , expires_at ; `); @@ -113,8 +113,8 @@ export const action: Action = async exec => { target , coordinate , hash - , toStartOfHour(timestamp) - , toStartOfHour(expires_at) + , timestamp + , expires_at ; `); @@ -156,10 +156,8 @@ export const action: Action = async exec => { target , coordinate , hash - -- group by prioritizes the original values over aliases. Call the same time modifiers here to correctly group data - -- in order to be performant - , toStartOfDay(timestamp) - , toStartOfDay(expires_at) + , timestamp + , expires_at ; `); }; From 55ce90390813e15d45ad0fdd8b834926a81cb4ab Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 26 May 2026 21:54:05 -0700 Subject: [PATCH 15/62] Provide example operations for migration tables --- .../018-usage-coordinate-errors.ts | 89 +++++++++++++++++-- .../019-usage-coordinate-counts.ts | 39 ++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 9724be5a336..8598630faf5 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -10,6 +10,8 @@ export const action: Action = async exec => { /** * This is a source table that gets projected to various data points. Therefore this table's * TTL does not need to be dictated by the customer data lifespan. + * + * This table should not be queried directly given any of our known patterns. */ await exec(` CREATE TABLE IF NOT EXISTS default.operation_errors @@ -38,7 +40,86 @@ export const action: Action = async exec => { ; `); - /** Minutely metric table */ + /** + * Minutely metric table + * + * ---------------- + * + * The primary key is ordered such that it supports a "supergraph" view. Which is by coordinate. + * + * Used to render total errors for a coordinate (e.g. on explorer page). This also supports + * grouping by error code, which allows showing coordinate errors by code over time. + * + * (1) "What is the breakdown of errors for 'User.ssn' over time?" + * + * SELECT code, sum(total_errors) as total + * FROM default.coordinate_errors_minutely + * WHERE target = '' + * AND coordinate = 'User.ssn' + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY + * coordinate, code + * + * (2) What are the top errors in the graph + * SELECT + * code + * , sum(total_errors) AS total + * FROM default.coordinate_errors_minutely + * WHERE target = '' + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY code + * ORDER BY total DESC + * LIMIT 10; + * + * (3) "For a coordinate, what is the availability per operation?" + * (This requires fetching the total requested count also from a separate table and doing the division) + * + * SELECT + * hex(hash) AS hash, -- Converts the 16-byte binary hash to a readable 32-character hex string + * code, + * sum(total_errors) AS total + * FROM default.coordinate_errors_minutely + * WHERE target = '' + * AND coordinate = 'User.ssn' + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY + * hash, + * code + * ORDER BY + * total DESC; + * + * --------------- + * + * The projection offers another view into this data, which is focused on the operation. + * It's critical to be able to collect all values by hash to give users insight into how + * that specific operation is performing and where it's failing. Such as: + * + * (1) "How many errors an operation (hash) returns over time" + * + * SELECT sum(total_errors) as total + * FROM default.coordinate_errors_minutely + * WHERE target= '' + * AND hash = unhex('') + * AND timestamp >= now() - INTERVAL 2 HOUR + * + * + * (2) "top error coordinates for a hash" + * + * SELECT + * , coordinate + * , sum(total_errors) AS total + * FROM default.coordinate_errors_minutely + * WHERE target = '' + * AND hash = unhex('') + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY coordinate + * ORDER BY total DESC + * LIMIT 10; + * + * ------------------ + * + * This could also technically be used to calculate the total number of errors by + */ await exec(` CREATE TABLE IF NOT EXISTS default.coordinate_errors_minutely ( @@ -50,10 +131,8 @@ export const action: Action = async exec => { , code LowCardinality(String) CODEC(ZSTD(1)) , total_errors UInt32 CODEC(T64, ZSTD(1)) - -- To support querying by coordinate (for schema-wide metrics) - -- AND by hash (for operation specific metrics), create projection - -- that stores this same data in a different order. - -- And use explicit selects to make any future migrations easier + -- Create projection that stores this same data in a different order, + -- and use explicit selects to make any future migrations easier , PROJECTION hash_order ( SELECT target, hash, coordinate, code, timestamp, expires_at, total_errors ORDER BY (target, hash, coordinate, code, timestamp, expires_at) diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index 7084568208b..d36410ccf90 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -20,6 +20,30 @@ export const action: Action = async exec => { * This replaces the counts for display purpose (explorer page) by coordinate. The existing counts * represent the number of operations calls that used the coordinate, rather than the number of times * the coordinate was executed/resolved. + * + * The main query for table is to get the total number of calls to a coordinate, which can be used in + * conjunction with the coordinate_errors_minutely data to calculate availability. + * + * (1) Total request count for a coordinate (used to calculate availability) + * + * SELECT sum(total) as total + * FROM default.coordinate_counts_minutely + * WHERE target = '' + * AND coordinate = 'User.ssn' + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY + * coordinate + * + * (2) For an operation, how many times was a field requested? + * + * SELECT hash, coordinate, sum(total) as total + * FROM default.coordinate_counts_minutely + * WHERE target = '' + * AND hash = unhex('') + * AND timestamp >= now() - INTERVAL 2 HOUR + * GROUP BY + * coordinate + * */ await exec(` CREATE TABLE IF NOT EXISTS default.coordinate_counts_minutely @@ -39,6 +63,11 @@ export const action: Action = async exec => { , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , coordinate String CODEC(ZSTD(1)) , total UInt32 CODEC(T64, ZSTD(1)) + + , PROJECTION hash_order ( + SELECT target, hash, coordinate, timestamp, expires_at, total + ORDER BY (target, hash, coordinate, timestamp, expires_at) + ) ) -- The engine is kept as a merge tree to ENGINE = SummingMergeTree @@ -87,6 +116,11 @@ export const action: Action = async exec => { , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , coordinate String CODEC(ZSTD(1)) , total UInt32 CODEC(T64, ZSTD(1)) + + , PROJECTION hash_order ( + SELECT target, hash, coordinate, timestamp, expires_at, total + ORDER BY (target, hash, coordinate, timestamp, expires_at) + ) ) ENGINE = SummingMergeTree PARTITION BY toYYYYMMDD(timestamp) @@ -130,6 +164,11 @@ export const action: Action = async exec => { , expires_at DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) , coordinate String CODEC(ZSTD(1)) , total UInt32 CODEC(T64, ZSTD(1)) + + , PROJECTION hash_order ( + SELECT target, hash, coordinate, timestamp, expires_at, total + ORDER BY (target, hash, coordinate, timestamp, expires_at) + ) ) ENGINE = SummingMergeTree PARTITION BY toYYYYMM(timestamp) From 064fa8b4c4e7b3de3e1dd3b357f9446c8fe276e4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 27 May 2026 20:04:06 -0700 Subject: [PATCH 16/62] Create a gateway-usage plugin that supports subgraph usage data --- packages/libraries/apollo/src/index.ts | 28 ++- packages/libraries/core/src/client/client.ts | 4 +- packages/libraries/core/src/client/types.ts | 38 ++- packages/libraries/core/src/client/usage.ts | 97 +++++++- packages/libraries/core/tests/usage.spec.ts | 42 ++-- packages/libraries/envelop/src/index.ts | 6 +- packages/libraries/gateway-usage/LICENSE | 21 ++ packages/libraries/gateway-usage/package.json | 65 +++++ .../gateway-usage/src/extract-coordinates.ts | 127 ++++++++++ packages/libraries/gateway-usage/src/index.ts | 146 ++++++++++++ .../gateway-usage/src/is-entity-request.ts | 16 ++ .../gateway-usage/src/path-to-coordinate.ts | 49 ++++ .../libraries/gateway-usage/src/version.ts | 1 + .../gateway-usage/tests/gateway-usage.spec.ts | 222 ++++++++++++++++++ .../tests/is-entity-request.spec.ts | 22 ++ .../tests/path-to-coordinate.spec.ts | 34 +++ .../libraries/gateway-usage/tsconfig.json | 15 ++ packages/libraries/yoga/src/index.ts | 10 +- packages/services/usage-common/src/raw.ts | 1 + pnpm-lock.yaml | 17 ++ 20 files changed, 904 insertions(+), 57 deletions(-) create mode 100644 packages/libraries/gateway-usage/LICENSE create mode 100644 packages/libraries/gateway-usage/package.json create mode 100644 packages/libraries/gateway-usage/src/extract-coordinates.ts create mode 100644 packages/libraries/gateway-usage/src/index.ts create mode 100644 packages/libraries/gateway-usage/src/is-entity-request.ts create mode 100644 packages/libraries/gateway-usage/src/path-to-coordinate.ts create mode 100644 packages/libraries/gateway-usage/src/version.ts create mode 100644 packages/libraries/gateway-usage/tests/gateway-usage.spec.ts create mode 100644 packages/libraries/gateway-usage/tests/is-entity-request.spec.ts create mode 100644 packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts create mode 100644 packages/libraries/gateway-usage/tsconfig.json diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index c8a24b8cda4..fbc88eaa303 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -220,7 +220,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo let doc: DocumentNode; let didResolveSource = false; - const complete = hive.collectUsage(); + const usageCollection = hive.collectUsage(); const args = { schema: context.schema, get document() { @@ -241,7 +241,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo }, willSendResponse(ctx: any) { if (!didResolveSource) { - void complete(args, { + void usageCollection.finish(args, { action: 'abort', reason: 'Did not resolve source', logging: false, @@ -249,7 +249,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo return; } doc = ctx.document; - void complete(args, ctx.response); + void usageCollection.finish(args, ctx.response); }, } as any; } @@ -270,7 +270,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo }, async willSendResponse(ctx) { if (didFailValidation) { - void complete(args, { + void usageCollection.finish(args, { action: 'abort', reason: 'Validation failed', logging: false, @@ -278,7 +278,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo return; } if (!didResolveSource) { - void complete(args, { + void usageCollection.finish(args, { action: 'abort', reason: 'Did not resolve source', logging: false, @@ -288,7 +288,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo if (!ctx.document) { const details = ctx.operationName ? `operationName: ${ctx.operationName}` : ''; - void complete(args, { + void usageCollection.finish(args, { action: 'abort', reason: 'Document is not available' + (details ? ` (${details})` : ''), logging: true, @@ -297,7 +297,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo } doc = ctx.document!; - void complete(args, ctx.response as any); + void usageCollection.finish(args, ctx.response as any); }, }); } @@ -399,7 +399,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo }, async willSendResponse(ctx) { if (didFailValidation) { - void complete( + void usageCollection.finish( args, { action: 'abort', @@ -411,7 +411,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo return; } if (!didResolveSource) { - void complete( + void usageCollection.finish( args, { action: 'abort', @@ -425,7 +425,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo if (!ctx.document) { const details = ctx.operationName ? `operationName: ${ctx.operationName}` : ''; - void complete( + void usageCollection.finish( args, { action: 'abort', @@ -439,7 +439,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo doc = ctx.document; if (ctx.response.body.kind === 'incremental') { - void complete( + void usageCollection.finish( args, { action: 'abort', @@ -449,7 +449,11 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Apollo persistedDocumentHash, ); } else { - void complete(args, ctx.response.body.singleResult, persistedDocumentHash); + void usageCollection.finish( + args, + ctx.response.body.singleResult, + persistedDocumentHash, + ); } }, }; diff --git a/packages/libraries/core/src/client/client.ts b/packages/libraries/core/src/client/client.ts index 73c34d26379..47fd272dfcb 100644 --- a/packages/libraries/core/src/client/client.ts +++ b/packages/libraries/core/src/client/client.ts @@ -214,9 +214,9 @@ export function createHive(options: HivePluginOptions): HiveClient { const collect = usage.collect(); const result = executeImpl(args); if ('then' in result) { - void result.then(result => collect(args, result)); + void result.then(result => collect.finish(args, result)); } else { - void collect(args, result); + void collect.finish(args, result); } return result; diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index 6e5a9816ca0..25256419c60 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -1,4 +1,4 @@ -import type { ExecutionArgs } from 'graphql'; +import type { ExecutionArgs, GraphQLErrorExtensions } from 'graphql'; import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import { LogLevel as HiveLoggerLevel, Logger } from '@graphql-hive/logger'; import { MaybePromise } from '@graphql-tools/utils'; @@ -11,13 +11,46 @@ type HeadersObject = { get(name: string): string | null; }; +export type SubRequestCallback = (args: { + /** Which subgraph resolved this path */ + subgraph: string; + + /** + * If this is an entity request, then this is the coordinate in the original operation that is being resolved. + * If undefined, then the path is assumed to be 'Query'. + */ + paths?: string[] | string; + + /** + * What type of request this is. Root is if resolving a root query/mutation field. Entity is + * if resolving an entity type in federation. + * */ + type: 'ROOT' | 'ENTITY'; +}) => FinishSubRequestCallback; + +export type FinishSubRequestCallback = (result: { + /** HTTP Status Code */ + status: number; + + /** Number of times the field has been requested. Regardless of success or failure */ + fields: { [coordinate: string]: number }; + + /** Error code for a coordinate, with a code returned from the graphql extensions */ + errors?: { coordinate: string; code?: string }[]; +}) => void; + +export type CollectUsage = { + subrequest: SubRequestCallback; + finish: CollectUsageCallback; +}; + export interface HiveClient { [hiveClientSymbol]: true; [autoDisposeSymbol]: boolean | NodeJS.Signals[]; info(): void | Promise; reportSchema: SchemaReporter['report']; /** Collect usage for Query and Mutation operations */ - collectUsage(): CollectUsageCallback; + collectUsage(): CollectUsage; /** Collect usage for Query and Mutation operations */ collectRequest(args: { args: ExecutionArgs; @@ -284,6 +317,7 @@ export interface GraphQLErrorsResult { errors?: ReadonlyArray<{ message: string; path?: Maybe>; + extensions?: GraphQLErrorExtensions; }>; } diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index 275d50e0f2c..b0c77f1f685 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -15,6 +15,7 @@ import { dynamicSampling, randomSampling } from './sampling.js'; import type { AbortAction, ClientInfo, + CollectUsage, CollectUsageCallback, GraphQLErrorsResult, HiveInternalPluginOptions, @@ -30,7 +31,7 @@ import { } from './utils.js'; interface UsageCollector { - collect(): CollectUsageCallback; + collect(): CollectUsage; /** collect a short lived GraphQL request (mutation/query operation) */ collectRequest(args: { args: ExecutionArgs; @@ -38,6 +39,8 @@ interface UsageCollector { /** duration in milliseconds */ duration: number; experimental__persistedDocumentHash?: string; + /** Optionally send subgraph request information. This provides a deeper level of usage metrics */ + fetches?: OperationSubgraphRequest[]; }): void; /** collect a long-lived GraphQL request/subscription (subscription operation) */ collectSubscription(args: { @@ -53,7 +56,12 @@ function isAbortAction(result: Parameters[1]): result is A const noopUsageCollector: UsageCollector = { collect() { - return async () => {}; + return { + subrequest() { + return () => {}; + }, + async finish() {}, + }; }, collectRequest() {}, async dispose() {}, @@ -123,6 +131,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ok: operation.execution.ok, duration: operation.execution.duration, errorsTotal: operation.execution.errorsTotal, + fetches: operation.execution.fetches, }, metadata: { client: operation.client ?? undefined, @@ -226,7 +235,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ) { const errors = args.result.errors?.map(error => ({ - message: error.message, + code: error.extensions?.code, path: error.path?.join('.'), })) ?? []; const collect = collector({ @@ -250,7 +259,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ok: errors.length === 0, duration: args.duration, errorsTotal: errors.length, - errors, + fetches: args.fetches ?? [], }, // TODO: operationHash is ready to accept hashes of persisted operations client: args.experimental__persistedDocumentHash @@ -275,13 +284,47 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl return { dispose: agent.dispose, + /** The raw request collection function */ collectRequest, + /** + * A more advanced method of collecting the request that includes calculating durations + * automatically and supports subrequest data. + */ collect() { - const finish = measureDuration(); + const sinceStart = measureDuration(); + const fetches: OperationSubgraphRequest[] = []; + const collectSubRequest = (args: OperationSubgraphRequest) => { + fetches.push(args); + }; - return async function complete(args, result, experimental__persistedDocumentHash) { - const duration = finish(); - return collectRequest({ args, result, duration, experimental__persistedDocumentHash }); + return { + subrequest(args) { + const start = sinceStart(); + const sinceSubStart = measureDuration(); + return result => { + const duration = sinceSubStart(); + collectSubRequest({ + start, + duration, + fields: result.fields, + status: result.status, + subgraph: args.subgraph, + type: args.type, + errors: result.errors, + paths: args.paths, + }); + }; + }, + async finish(args, result, experimental__persistedDocumentHash) { + const duration = sinceStart(); + return collectRequest({ + args, + result, + duration, + experimental__persistedDocumentHash, + fetches, + }); + }, }; }, async collectSubscription({ args, experimental__persistedDocumentHash }) { @@ -407,6 +450,38 @@ type AgentAction = data: CollectedSubscriptionOperation; }; +type OperationSubgraphRequest = { + /** Delta start time from "timestamp" */ + start: number; + + /** How long the request took */ + duration: number; + + /** HTTP Status Code */ + status: number; + + /** Number of times the field has been requested. Regardless of success or failure */ + fields: { [coordinate: string]: number }; + + /** Error code for a coordinate, with a code returned from the graphql extensions */ + errors?: { coordinate: string; code?: string }[]; + + /** Which subgraph resolved this path */ + subgraph: string; + + /** + * If this is an entity request, then this is the coordinate in the original operation that is being resolved. + * If undefined, then the path is assumed to be 'Query'. + */ + paths?: string[] | string; + + /** + * What type of request this is. Root is if resolving a root query/mutation field. Entity is + * if resolving an entity type in federation. + * */ + type: 'ROOT' | 'ENTITY'; +}; + interface CollectedOperation { key: string; timestamp: number; @@ -417,10 +492,7 @@ interface CollectedOperation { ok: boolean; duration: number; errorsTotal: number; - errors?: Array<{ - message: string; - path?: string; - }>; + fetches: OperationSubgraphRequest[]; }; persistedDocumentHash?: string; client?: ClientInfo | null; @@ -443,6 +515,7 @@ interface RequestOperation { ok: boolean; duration: number; errorsTotal: number; + fetches: OperationSubgraphRequest[]; }; persistedDocumentHash?: string; metadata?: { diff --git a/packages/libraries/core/tests/usage.spec.ts b/packages/libraries/core/tests/usage.spec.ts index 0f5eef7b57e..f97815cce85 100644 --- a/packages/libraries/core/tests/usage.spec.ts +++ b/packages/libraries/core/tests/usage.spec.ts @@ -159,7 +159,7 @@ test('should send data to Hive', async () => { const collect = hive.collectUsage(); await waitFor(20); - await collect( + await collect.finish( { schema, document: op, @@ -269,7 +269,7 @@ test('should send data to Hive (deprecated endpoint)', async () => { const collect = hive.collectUsage(); await waitFor(20); - await collect( + await collect.finish( { schema, document: op, @@ -360,7 +360,7 @@ test('should not leak the exception', { retry: 3 }, async () => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -423,7 +423,7 @@ test('sendImmediately should not stop the schedule', async () => { const collect = hive.collectUsage(); - await collect( + await collect.finish( { schema, document: op, @@ -443,12 +443,12 @@ test('sendImmediately should not stop the schedule', async () => { // Now we will hit the maxSize // We run collect two times - await Promise.all([collect(args, {}), collect(args, {})]); + await Promise.all([collect.finish(args, {}), collect.finish(args, {})]); await waitUntil(() => logger.getLogs().includes('Sending immediately')); expect(logger.getLogs()).toMatch('Sending report (queue 2)'); logger.clear(); // Let's check if the scheduled send task is still running - await collect(args, {}); + await collect.finish(args, {}); await waitFor(60); expect(logger.getLogs()).toMatch('Sending report (queue 1)'); @@ -510,7 +510,7 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy const collect = hive.collectUsage(); await Promise.all([ - collect( + collect.finish( { schema, document: op, @@ -519,7 +519,7 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy {}, ), // different query - collect( + collect.finish( { schema, document: op2, @@ -528,7 +528,7 @@ test('should send data to Hive at least once when using atLeastOnceSampler', asy {}, ), // duplicated call - collect( + collect.finish( { schema, document: op, @@ -606,7 +606,7 @@ test('should not send excluded operation name data to Hive', async () => { const collect = hive.collectUsage(); await waitFor(20); await Promise.all([ - (collect( + (collect.finish( { schema, document: op, @@ -614,7 +614,7 @@ test('should not send excluded operation name data to Hive', async () => { }, {}, ), - collect( + collect.finish( { schema, document: op, @@ -622,7 +622,7 @@ test('should not send excluded operation name data to Hive', async () => { }, {}, ), - collect( + collect.finish( { schema, document: op, @@ -630,7 +630,7 @@ test('should not send excluded operation name data to Hive', async () => { }, {}, ), - collect( + collect.finish( { schema, document: op2, @@ -732,7 +732,7 @@ test('retry on non-200', async () => { const collect = hive.collectUsage(); - await collect( + await collect.finish( { schema, document: op, @@ -784,7 +784,7 @@ test('constructs URL with usage.target (hvo1/)', async ({ expect }) => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -830,7 +830,7 @@ test('constructs URL with usage.target (hvp1/)', async ({ expect }) => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -876,7 +876,7 @@ test('constructs URL with usage.target (hvu1/)', async ({ expect }) => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -919,7 +919,7 @@ test('no debug property -> logger.debug is invoked', async ({ expect }) => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -969,7 +969,7 @@ test('debug: false -> logger.debug is not invoked', async ({ expect }) => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -1023,7 +1023,7 @@ test('debug: true and missing logger.debug method -> logger.info is invoked (to }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, @@ -1087,7 +1087,7 @@ test('new logger option', async () => { }, }); - await hive.collectUsage()( + await hive.collectUsage().finish( { schema, document: op, diff --git a/packages/libraries/envelop/src/index.ts b/packages/libraries/envelop/src/index.ts index 0f57b1abc25..7c00d710f5f 100644 --- a/packages/libraries/envelop/src/index.ts +++ b/packages/libraries/envelop/src/index.ts @@ -59,12 +59,12 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Plugin hive.reportSchema({ schema }); }, onExecute({ args }) { - const complete = hive.collectUsage(); + const usageCollection = hive.collectUsage(); return { onExecuteDone({ result }) { if (!isAsyncIterable(result)) { - void complete(args, result); + void usageCollection.finish(args, result); return; } @@ -76,7 +76,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Plugin } }, onEnd() { - void complete(args, errors.length ? { errors } : {}); + void usageCollection.finish(args, errors.length ? { errors } : {}); }, }; }, diff --git a/packages/libraries/gateway-usage/LICENSE b/packages/libraries/gateway-usage/LICENSE new file mode 100644 index 00000000000..1cf5b9c7d23 --- /dev/null +++ b/packages/libraries/gateway-usage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 The Guild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/libraries/gateway-usage/package.json b/packages/libraries/gateway-usage/package.json new file mode 100644 index 00000000000..ac40515dd32 --- /dev/null +++ b/packages/libraries/gateway-usage/package.json @@ -0,0 +1,65 @@ +{ + "name": "@graphql-hive/gateway-usage", + "version": "0.1.0", + "type": "module", + "description": "GraphQL Hive + GraphQL Hive Gateway", + "repository": { + "type": "git", + "url": "graphql-hive/platform", + "directory": "packages/libraries/gateway" + }, + "homepage": "https://the-guild.dev/graphql/hive", + "author": { + "email": "contact@the-guild.dev", + "name": "The Guild", + "url": "https://the-guild.dev" + }, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "scripts": { + "build": "node ../../../scripts/generate-version.mjs && bob build", + "check:build": "bob check" + }, + "peerDependencies": { + "@graphql-hive/gateway": "^2.0.0", + "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "dependencies": { + "@graphql-hive/core": "workspace:*" + }, + "devDependencies": { + "@envelop/types": "5.0.0", + "@graphql-hive/gateway": "^2.0.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public", + "directory": "dist" + }, + "sideEffects": false, + "typescript": { + "definition": "dist/typings/index.d.ts" + } +} diff --git a/packages/libraries/gateway-usage/src/extract-coordinates.ts b/packages/libraries/gateway-usage/src/extract-coordinates.ts new file mode 100644 index 00000000000..7a8dbb7a5df --- /dev/null +++ b/packages/libraries/gateway-usage/src/extract-coordinates.ts @@ -0,0 +1,127 @@ +import { + DocumentNode, + getNamedType, + GraphQLSchema, + GraphQLType, + isInterfaceType, + isObjectType, + isUnionType, + Kind, + OperationDefinitionNode, + SelectionNode, +} from 'graphql'; + +/** + * Extracts true schema coordinates and counts their execution volume. + */ +export function extractSchemaCoordinates( + schema: GraphQLSchema, + document: DocumentNode, + resultData: any, +): Record { + const counts: Record = {}; + + // 1. Find the root operation (Query, Mutation, Subscription) + const operation = document.definitions.find( + (def): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION, + ); + if (!operation) return counts; + + // 2. Determine the root schema type + let rootType; + if (operation.operation === 'query') rootType = schema.getQueryType(); + else if (operation.operation === 'mutation') rootType = schema.getMutationType(); + else if (operation.operation === 'subscription') rootType = schema.getSubscriptionType(); + + if (!rootType || !resultData) return counts; + + // 3. Start the synchronized walk + walkZip(resultData, operation.selectionSet.selections, rootType, schema, counts); + + return counts; +} + +function walkZip( + data: any, + selections: readonly SelectionNode[], + parentType: GraphQLType, + schema: GraphQLSchema, + counts: Record, +) { + if (Array.isArray(data)) { + for (const item of data) { + walkZip(item, selections, parentType, schema, counts); + } + return; + } + + if (data === null || typeof data !== 'object') { + return; + } + + const namedType = getNamedType(parentType); + if (!isObjectType(namedType) && !isInterfaceType(namedType) && !isUnionType(namedType)) { + return; + } + + // Get fields, but safely handle Union types which don't have direct fields + const fields = 'getFields' in namedType ? namedType.getFields() : {}; + + for (const selection of selections) { + if (selection.kind === Kind.FIELD) { + const realFieldName = selection.name.value; + const responseKey = selection.alias ? selection.alias.value : realFieldName; + + // Special case for __typename which isn't in the schema fields map + if (realFieldName === '__typename') { + continue; + } + + if (responseKey in data) { + const coordinate = `${namedType.name}.${realFieldName}`; + counts[coordinate] = (counts[coordinate] || 0) + 1; + + if (selection.selectionSet && fields[realFieldName]) { + walkZip( + data[responseKey], + selection.selectionSet.selections, + fields[realFieldName].type, + schema, + counts, + ); + } + } + } + + // --- INLINE FRAGMENT LOGIC --- + else if (selection.kind === Kind.INLINE_FRAGMENT) { + const typeConditionName = selection.typeCondition?.name.value; + + let matchesType = true; + let nextType: GraphQLType = namedType; + + if (typeConditionName) { + // In federated execution, abstract types (Interfaces/Unions) generally + // return __typename in the payload. We use that to verify the fragment match. + const runtimeTypeName = data.__typename || namedType.name; + matchesType = typeConditionName === runtimeTypeName; + + if (matchesType) { + nextType = schema.getType(typeConditionName) || namedType; + } + } + + // If the runtime data matches the fragment's type condition, + // recurse using the SAME data node, but the fragment's selection set. + if (matchesType && selection.selectionSet) { + walkZip( + data, // <-- Pass the exact same data object + selection.selectionSet.selections, + nextType, // <-- Pass the narrowed type + schema, + counts, + ); + } + } + } +} diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts new file mode 100644 index 00000000000..a36f0854146 --- /dev/null +++ b/packages/libraries/gateway-usage/src/index.ts @@ -0,0 +1,146 @@ +import { responsePathAsArray, type GraphQLError } from 'graphql'; +import { + autoDisposeSymbol, + createHive as createHiveClient, + HiveClient, + HivePluginOptions, + isAsyncIterable, + isHiveClient, +} from '@graphql-hive/core'; +import type { CollectUsage } from '@graphql-hive/core/src/client/types.js'; +import type { GatewayPlugin } from '@graphql-hive/gateway'; +import { extractSchemaCoordinates } from './extract-coordinates.js'; +import { isEntityRequest } from './is-entity-request.js'; +import { pathToCoordinate } from './path-to-coordinate.js'; +import { version } from './version.js'; + +export function createHive(clientOrOptions: HivePluginOptions) { + return createHiveClient({ + ...clientOrOptions, + agent: { + name: 'hive-client-yoga', + version, + ...clientOrOptions.agent, + }, + }); +} + +export function useHive(clientOrOptions: HiveClient): GatewayPlugin; +export function useHive(clientOrOptions: HivePluginOptions): GatewayPlugin; +export function useHive(clientOrOptions: HiveClient | HivePluginOptions): GatewayPlugin { + const hive = isHiveClient(clientOrOptions) + ? clientOrOptions + : createHive({ + ...clientOrOptions, + agent: { + name: 'hive-client-envelop', + ...clientOrOptions.agent, + }, + }); + + void hive.info(); + + if (hive[autoDisposeSymbol]) { + if (global.process) { + const signals = Array.isArray(hive[autoDisposeSymbol]) + ? hive[autoDisposeSymbol] + : ['SIGINT', 'SIGTERM']; + for (const signal of signals) { + process.once(signal, () => hive.dispose()); + } + } else { + console.error( + 'It seems that GraphQL Hive is not being executed in Node.js. ' + + 'Please attempt manual client disposal and use autoDispose: false option.', + ); + } + } + + return { + onSubgraphExecute({ executionRequest, subgraphName, subgraph: subgraphSchema }) { + const collection = executionRequest.context?.__hiveUsageCollection as + | CollectUsage + | undefined; + + if (!collection) { + // This is set onExecute so this should exist... but just to be safe + return; + } + + const finishSubRequest = collection.subrequest({ + subgraph: subgraphName, + type: isEntityRequest(executionRequest.document) ? 'ENTITY' : 'ROOT', + /** @NOTE this field's format supports batched requests, but onSubgraphExecute does not. */ + paths: executionRequest.info?.path + ? [responsePathAsArray(executionRequest.info.path).join('.')] + : [], + }); + + return function onSubgraphExecuteDone({ result }) { + if (!isAsyncIterable(result)) { + let errors: { coordinate: string; code?: string }[] | undefined = undefined; + if (result.errors) { + errors = []; + for (const err of result.errors) { + const coordinate = err.path && pathToCoordinate(subgraphSchema, err.path); + if (coordinate) { + errors.push({ + coordinate, + code: err.extensions?.code as string | undefined, + }); + } + } + } + + const fields = extractSchemaCoordinates( + subgraphSchema, + executionRequest.document, + result.data, + ); + + finishSubRequest({ + status: 200 /** @TODO figure out how to capture HTTP status codes */, + fields, + errors, + }); + } + }; + }, + onSchemaChange({ schema }) { + hive.reportSchema({ schema }); + }, + onExecute({ args }) { + const collection = hive.collectUsage(); + + // Inject the collection object into the GraphQL context + // so it can be accessed downstream by subgraph executions. + if (args.contextValue) { + (args.contextValue as any).__hiveUsageCollection = collection; + } + + return { + onExecuteDone({ result }) { + if (!isAsyncIterable(result)) { + void collection.finish(args, result); + return; + } + + const errors: GraphQLError[] = []; + return { + onNext(ctx) { + if (ctx.result.errors) { + errors.push(...ctx.result.errors); + } + }, + onEnd() { + void collection.finish(args, errors.length ? { errors } : {}); + }, + }; + }, + }; + }, + onSubscribe({ args }) { + hive.collectSubscriptionUsage({ args }); + }, + }; +} diff --git a/packages/libraries/gateway-usage/src/is-entity-request.ts b/packages/libraries/gateway-usage/src/is-entity-request.ts new file mode 100644 index 00000000000..4918adc77fd --- /dev/null +++ b/packages/libraries/gateway-usage/src/is-entity-request.ts @@ -0,0 +1,16 @@ +import type { DocumentNode } from 'graphql'; + +/** + * Checks if the top-level selection is an '_entities' query + */ +export function isEntityRequest(document: DocumentNode) { + let isEntityRequest = false; + const operation = document.definitions.find(def => def.kind === 'OperationDefinition'); + + if (operation && operation.selectionSet) { + isEntityRequest = operation.selectionSet.selections.some( + selection => selection.kind === 'Field' && selection.name.value === '_entities', + ); + } + return isEntityRequest; +} diff --git a/packages/libraries/gateway-usage/src/path-to-coordinate.ts b/packages/libraries/gateway-usage/src/path-to-coordinate.ts new file mode 100644 index 00000000000..1587071dc02 --- /dev/null +++ b/packages/libraries/gateway-usage/src/path-to-coordinate.ts @@ -0,0 +1,49 @@ +import { + getNamedType, + GraphQLFieldMap, + GraphQLSchema, + isInterfaceType, + isObjectType, +} from 'graphql'; + +export function pathToCoordinate( + schema: GraphQLSchema, + errorPath: readonly (string | number)[], + operationType: 'mutation' | 'subscription' | 'query' = 'query', +): string | undefined { + // 1. Start at the root operation type + let currentType; + if (operationType === 'mutation') currentType = schema.getMutationType(); + else if (operationType === 'subscription') currentType = schema.getSubscriptionType(); + else currentType = schema.getQueryType(); + + let coordinate = null; + + for (const segment of errorPath) { + // 2. Skip array indices entirely (they don't change the underlying type) + if (typeof segment === 'number') continue; + + // 3. Unwrap Non-Null (!) and List ([]) types to get the base Object/Interface + currentType = getNamedType(currentType); + + // 4. Ensure the current type has fields + if (isObjectType(currentType) || isInterfaceType(currentType)) { + const fields: GraphQLFieldMap = currentType.getFields(); + const field = fields[segment]; + + if (!field) { + throw new Error( + `Field '${segment}' not found on type '${currentType.name}'. Was this aliased?`, + ); + } + + // Update the coordinate to the current Type.field + coordinate = `${currentType.name}.${field.name}`; + + // Move deeper into the tree for the next iteration + currentType = field.type; + } + } + + return coordinate ?? undefined; +} diff --git a/packages/libraries/gateway-usage/src/version.ts b/packages/libraries/gateway-usage/src/version.ts new file mode 100644 index 00000000000..7030d4a7c50 --- /dev/null +++ b/packages/libraries/gateway-usage/src/version.ts @@ -0,0 +1 @@ +export const version = '0.1.0'; diff --git a/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts b/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts new file mode 100644 index 00000000000..271d5c7a3a1 --- /dev/null +++ b/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts @@ -0,0 +1,222 @@ +// THIS IS GOING TO BE MOVED AND MADE INTO AN INTEGRATION TEST. IT"LL BE BETTER BECAUSE IT WONT REQUIRE MOCKS. + +// import { beforeEach, describe, expect, it, vi } from 'vitest'; +// import { autoDisposeSymbol } from '@graphql-hive/core'; +// import { createGatewayRuntime, GatewayPlugin, GatewayRuntime } from '@graphql-hive/gateway'; +// import * as extractCoordinatesModule from '../src/extract-coordinates.js'; +// import { createHive, useHive } from '../src/index.js'; + +// const MINIMAL_SUPERGRAPH = ` +// schema +// @link(url: "https://specs.apollo.dev/link/v1.0") +// @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) +// { +// query: Query +// } +// directive @join__graph(name: String!, url: String!) on ENUM_VALUE +// directive @join__type(graph: join__Graph!, key: String) repeatable on OBJECT +// directive @link(url: String, as: String, for: String) repeatable on SCHEMA +// enum join__Graph { PRODUCTS @join__graph(name: "products", url: "http://products.svc.local/graphql") } + +// type Query @join__type(graph: PRODUCTS) { +// product: Product +// } +// type Product @join__type(graph: PRODUCTS, key: "id") { +// id: ID! +// price: Int +// } +// `; + +// describe('GraphQL Hive Plugin (Integration Style)', () => { +// beforeEach(() => { +// vi.restoreAllMocks(); +// }); + +// describe('useHive - Initialization & Disposal', () => { +// it('should autoDispose on provided signals', () => { +// const client = createHive({ enabled: false, token: 'dummy-token' }); +// client[autoDisposeSymbol] = ['SIGINT']; + +// const processOnceSpy = vi.spyOn(process, 'once').mockImplementation((_event, cb) => { +// return process; +// }); +// const disposeSpy = vi.spyOn(client, 'dispose').mockResolvedValue(undefined); + +// useHive(client); + +// expect(processOnceSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + +// // Execute the callback that was registered +// const registeredCallback = processOnceSpy.mock.calls[0][1] as Function; +// registeredCallback(); +// expect(disposeSpy).toHaveBeenCalledOnce(); +// }); +// }); + +// describe('useHive - Plugin Hooks', () => { +// let client: ReturnType; +// let plugin: GatewayPlugin; +// let gateway: GatewayRuntime; + +// beforeEach(() => { +// client = createHive({ enabled: false, token: 'dummy-token' }); +// plugin = useHive(client); +// gateway = createGatewayRuntime({ +// supergraph: MINIMAL_SUPERGRAPH, +// plugins: () => [plugin], +// }); +// }); + +// it('onSchemaChange should call reportSchema on the client', () => { +// const reportSchemaSpy = vi.spyOn(client, 'reportSchema').mockResolvedValue(undefined); +// const fakeSchema = {} as any; + +// plugin.onSchemaChange!({ schema: fakeSchema } as any); + +// expect(reportSchemaSpy).toHaveBeenCalledWith({ schema: fakeSchema }); +// }); + +// it('onSubscribe should call collectSubscriptionUsage on the client', () => { +// const collectSubSpy = vi +// .spyOn(client, 'collectSubscriptionUsage') +// .mockImplementation(() => {}); +// const fakeArgs = { document: {} } as any; + +// plugin.onSubscribe!({ args: fakeArgs } as any); + +// expect(collectSubSpy).toHaveBeenCalledWith({ args: fakeArgs }); +// }); + +// describe('onExecute', () => { +// it('should inject the collection into context and handle sync results', async () => { +// // Spy on the real collectUsage method +// const fakeCollection = { finish: vi.fn() } as any; +// vi.spyOn(client, 'collectUsage').mockReturnValue(fakeCollection); + +// const args = { contextValue: {} } as any; +// const result = { data: { id: '1' } } as any; + +// gateway.handleRequest) +// const onExecuteHook = await plugin.onExecute!({ args } as any); + +// // Context should be mutated +// expect(args.contextValue.__hiveUsageCollection).toBe(fakeCollection); + +// // Handle Sync Execution +// onExecuteHook!.onExecuteDone!({ result } as any); +// expect(fakeCollection.finish).toHaveBeenCalledWith(args, result); +// }); + +// it('should handle async iterable results correctly', async () => { +// const fakeCollection = { finish: vi.fn() } as any; +// vi.spyOn(client, 'collectUsage').mockReturnValue(fakeCollection); + +// const args = { contextValue: {} } as any; +// const onExecuteHook = await plugin.onExecute!({ args } as any); + +// // Create a real async generator to trigger the `isAsyncIterable` path +// async function* mockAsyncIterable() { +// yield { errors: [{ message: 'Stream Error 1' }] }; +// yield { errors: [{ message: 'Stream Error 2' }] }; +// } +// const result = mockAsyncIterable(); + +// const asyncHooks = (await onExecuteHook!.onExecuteDone!({ result } as any))!; + +// // Simulate Yoga streaming hooks +// asyncHooks.onNext!({ result: { errors: [{ message: 'Stream Error 1' }] } } as any); +// asyncHooks.onNext!({ result: { errors: [{ message: 'Stream Error 2' }] } } as any); +// asyncHooks.onEnd!(); + +// // Should collect and format all errors from the stream +// expect(fakeCollection.finish).toHaveBeenCalledWith(args, { +// errors: [{ message: 'Stream Error 1' }, { message: 'Stream Error 2' }], +// }); +// }); +// }); + +// describe('onSubgraphExecute', () => { +// it('should abort early if usage tracking context is missing', () => { +// const hookReturn = plugin.onSubgraphExecute!({ +// executionRequest: { context: {} } as any, +// subgraphName: 'products', +// } as any); +// expect(hookReturn).toBeUndefined(); +// }); + +// it('should finalize subgraph timers and handle errors via finishSubRequest', () => { +// // 1. Setup our fake finishSubRequest callback +// const finishSubRequestSpy = vi.fn(); +// const fakeCollection = { +// subrequest: vi.fn().mockReturnValue(finishSubRequestSpy), +// } as any; + +// // 2. Setup the coordinate extraction spy (so we don't need a massive real GraphQLSchema object) +// vi.spyOn(extractCoordinatesModule, 'extractSchemaCoordinates').mockReturnValue({ +// 'Product.price': 2, +// }); + +// // 3. Trigger the pre-subgraph hook +// const onSubgraphExecuteDone = plugin.onSubgraphExecute!({ +// executionRequest: { +// context: { __hiveUsageCollection: fakeCollection }, +// info: { schema: {} }, +// document: {}, +// rootValue: { __typename: '_Entity' }, // Triggers the ENTITY type path +// } as any, +// subgraphName: 'products', +// } as any) as Function; + +// // Verify the subrequest was started with 'ENTITY' +// expect(fakeCollection.subrequest).toHaveBeenCalledWith({ +// subgraph: 'products', +// type: 'ENTITY', +// paths: undefined, +// }); + +// // 4. Trigger the post-subgraph hook with a simulated error +// const mockResult = { +// data: null, +// errors: [{ path: ['product', 'price'], extensions: { code: 'UNAUTHORIZED' } }], +// }; + +// onSubgraphExecuteDone({ result: mockResult }); + +// // Verify completion handles the 500 status and maps the error formatting +// expect(finishSubRequestSpy).toHaveBeenCalledWith({ +// status: 500, +// fields: { 'Product.price': 2 }, +// errors: [{ coordinate: 'product.price', code: 'UNAUTHORIZED' }], +// }); +// }); + +// it('should return a 200 status when the subgraph yields no errors', () => { +// const finishSubRequestSpy = vi.fn(); +// const fakeCollection = { +// subrequest: vi.fn().mockReturnValue(finishSubRequestSpy), +// } as any; + +// vi.spyOn(extractCoordinatesModule, 'extractSchemaCoordinates').mockReturnValue({}); + +// const onSubgraphExecuteDone = plugin.onSubgraphExecute!({ +// executionRequest: { +// context: { __hiveUsageCollection: fakeCollection }, +// info: { schema: {} }, +// document: {}, +// } as any, +// subgraphName: 'users', +// } as any) as Function; + +// // Simulate successful result +// onSubgraphExecuteDone({ result: { data: { users: [] } } }); + +// expect(finishSubRequestSpy).toHaveBeenCalledWith( +// expect.objectContaining({ +// status: 200, +// errors: undefined, +// }), +// ); +// }); +// }); +// }); +// }); diff --git a/packages/libraries/gateway-usage/tests/is-entity-request.spec.ts b/packages/libraries/gateway-usage/tests/is-entity-request.spec.ts new file mode 100644 index 00000000000..2a323f4fbc5 --- /dev/null +++ b/packages/libraries/gateway-usage/tests/is-entity-request.spec.ts @@ -0,0 +1,22 @@ +import { parse } from 'graphql'; +import { isEntityRequest } from '../src/is-entity-request.js'; + +describe('is entity request returns', () => { + test('false for normal query', () => { + const operation = parse(/** graphql */ ` + query Users { + users { id } + } + `); + expect(isEntityRequest(operation)).toBe(false); + }); + + test('true for an entity query', () => { + const operation = parse(/** graphql */ ` + query NameDoesntMatter { + _entities(representations: [{ __typename: "User", id: "1" }]) { ...on User { id, email } } + } + `); + expect(isEntityRequest(operation)).toBe(true); + }); +}); diff --git a/packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts b/packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts new file mode 100644 index 00000000000..66247f5427e --- /dev/null +++ b/packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts @@ -0,0 +1,34 @@ +import { buildSchema } from 'graphql'; +import { pathToCoordinate } from '../src/path-to-coordinate.js'; + +const schema = buildSchema(` + type Query { + users: [User!]! + ids: [ID!] + } + + type User { + id: ID! + email: String! + status: UserStatus! + } + + enum UserStatus { + HEALTHY + } + +`); + +describe('can determine determines using a path', () => { + test('inside arrays', () => { + expect(pathToCoordinate(schema, ['users', 1, 'status'])).toBe('User.status'); + }); + + test('within an array', () => { + expect(pathToCoordinate(schema, ['ids', 1])).toBe('Query.ids'); + }); + + test('at root', () => { + expect(pathToCoordinate(schema, ['users'])).toBe('Query.users'); + }); +}); diff --git a/packages/libraries/gateway-usage/tsconfig.json b/packages/libraries/gateway-usage/tsconfig.json new file mode 100644 index 00000000000..31d8323b929 --- /dev/null +++ b/packages/libraries/gateway-usage/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "types": ["node"], + "baseUrl": ".", + "outDir": "dist", + "rootDir": "src", + "target": "es2017", + "module": "esnext", + "skipLibCheck": true, + "declaration": true, + "declarationMap": true + } +} diff --git a/packages/libraries/yoga/src/index.ts b/packages/libraries/yoga/src/index.ts index 627254ce7c3..7f50ab62c62 100644 --- a/packages/libraries/yoga/src/index.ts +++ b/packages/libraries/yoga/src/index.ts @@ -2,13 +2,13 @@ import { DocumentNode, ExecutionArgs, GraphQLError, GraphQLSchema, Kind, parse } import { _createLRUCache, YogaServer, type GraphQLParams, type Plugin } from 'graphql-yoga'; import { autoDisposeSymbol, - CollectUsageCallback, createHive as createHiveClient, HiveClient, HivePluginOptions, isAsyncIterable, isHiveClient, } from '@graphql-hive/core'; +import { CollectUsage } from '@graphql-hive/core/typings/client/types.js'; import { Logger } from '@graphql-hive/logger'; import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; import { version } from './version.js'; @@ -22,7 +22,7 @@ export { export type { SupergraphSDLFetcherOptions } from '@graphql-hive/core'; type CacheRecord = { - callback: CollectUsageCallback; + callback: CollectUsage; paramsArgs: GraphQLParams; executionArgs?: ExecutionArgs; parsedDocument?: DocumentNode; @@ -101,7 +101,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Plugin if (!isAsyncIterable(result)) { args.contextValue.waitUntil( - record.callback( + record.callback.finish( { ...record.executionArgs, document: record.parsedDocument ?? record.executionArgs.document, @@ -124,7 +124,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Plugin }, onEnd() { args.contextValue.waitUntil( - record.callback( + record.callback.finish( args, errors.length ? { errors } : {}, record.experimental__documentId, @@ -168,7 +168,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Plugin parsedDocumentCache.set(record.paramsArgs.query, document); } serverContext.waitUntil( - record.callback( + record.callback.finish( { document, schema: latestSchema, diff --git a/packages/services/usage-common/src/raw.ts b/packages/services/usage-common/src/raw.ts index a77273fa585..cc216165a38 100644 --- a/packages/services/usage-common/src/raw.ts +++ b/packages/services/usage-common/src/raw.ts @@ -27,6 +27,7 @@ export interface RawOperation { ok: boolean; duration: number; errorsTotal: number; + errors?: { code?: string; path?: string }[]; }; metadata?: { client?: ClientMetadata; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4a3582315b..ce3bc005ece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -648,6 +648,23 @@ importers: version: 16.9.0 publishDirectory: dist + packages/libraries/gateway-usage: + dependencies: + '@graphql-hive/core': + specifier: workspace:* + version: link:../core/dist + graphql: + specifier: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + version: 16.12.0 + devDependencies: + '@envelop/types': + specifier: 5.0.0 + version: 5.0.0 + '@graphql-hive/gateway': + specifier: ^2.0.0 + version: 2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(zod@4.3.6) + publishDirectory: dist + packages/libraries/laboratory: dependencies: '@base-ui/react': From 4b08973691e50e732db46bea16ee1e5a5198f43c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 27 May 2026 20:08:33 -0700 Subject: [PATCH 17/62] Update date on license for this new package --- packages/libraries/gateway-usage/LICENSE | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/libraries/gateway-usage/LICENSE b/packages/libraries/gateway-usage/LICENSE index 1cf5b9c7d23..fa3e3c814d9 100644 --- a/packages/libraries/gateway-usage/LICENSE +++ b/packages/libraries/gateway-usage/LICENSE @@ -1,21 +1,18 @@ MIT License -Copyright (c) 2022 The Guild +Copyright (c) 2026 The Guild -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES +OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 09b3a80c1baf139d0f31cd0150bd1f7c4c5fd0aa Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 29 May 2026 08:18:18 -0700 Subject: [PATCH 18/62] get gateway usage passing tests; add integration test to confirm ingestion works with new plugin --- integration-tests/package.json | 2 + .../tests/api/app-deployments.spec.ts | 10 +- .../tests/api/target/usage.spec.ts | 16 +- integration-tests/tests/cli/app.spec.ts | 2 +- .../tests/gateway-usage/plugin.spec.ts | 146 ++ packages/libraries/core/src/client/usage.ts | 10 +- packages/libraries/gateway-usage/package.json | 6 +- .../gateway-usage/src/extract-coordinates.ts | 20 +- packages/libraries/gateway-usage/src/index.ts | 9 +- .../gateway-usage/src/path-to-coordinate.ts | 4 +- .../gateway-usage/tests/gateway-usage.spec.ts | 222 --- .../gateway-usage/tests/index.spec.ts | 21 + packages/libraries/laboratory/package.json | 2 +- .../018-usage-coordinate-errors.ts | 12 +- .../019-usage-coordinate-counts.ts | 12 +- packages/services/usage/src/metric-helper.ts | 2 +- .../services/usage/src/usage-processor-2.ts | 50 + pnpm-lock.yaml | 1323 +++++++++-------- 18 files changed, 961 insertions(+), 908 deletions(-) create mode 100644 integration-tests/tests/gateway-usage/plugin.spec.ts delete mode 100644 packages/libraries/gateway-usage/tests/gateway-usage.spec.ts create mode 100644 packages/libraries/gateway-usage/tests/index.spec.ts diff --git a/integration-tests/package.json b/integration-tests/package.json index 17eefc6df93..eb677229bc8 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -17,6 +17,8 @@ "@esm2cjs/execa": "6.1.1-cjs.1", "@graphql-hive/apollo": "workspace:*", "@graphql-hive/core": "workspace:*", + "@graphql-hive/gateway-runtime": "^1.0.0 || ^2.0.0", + "@graphql-hive/gateway-usage": "workspace:*", "@graphql-typed-document-node/core": "3.2.0", "@hive/commerce": "workspace:*", "@hive/postgres": "workspace:*", diff --git a/integration-tests/tests/api/app-deployments.spec.ts b/integration-tests/tests/api/app-deployments.spec.ts index 422183217c2..4b75b75245d 100644 --- a/integration-tests/tests/api/app-deployments.spec.ts +++ b/integration-tests/tests/api/app-deployments.spec.ts @@ -2142,7 +2142,7 @@ test('app deployment usage reporting', async () => { }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { a }`), schema: buildASTSchema(parse(sdl)), @@ -2720,7 +2720,7 @@ test('activeAppDeployments filters by lastUsedBefore', async () => { }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { hello }`), schema: buildASTSchema(parse(sdl)), @@ -2882,7 +2882,7 @@ test('activeAppDeployments applies OR logic between lastUsedBefore and neverUsed }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { hello }`), schema: buildASTSchema(parse(sdl)), @@ -3159,7 +3159,7 @@ test('activeAppDeployments filters by name combined with lastUsedBefore', async }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { hello }`), schema: buildASTSchema(parse(sdl)), @@ -5602,7 +5602,7 @@ test('retire app deployment with --force bypasses protection', async () => { }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { hello }`), schema: buildASTSchema(parse(sdl)), diff --git a/integration-tests/tests/api/target/usage.spec.ts b/integration-tests/tests/api/target/usage.spec.ts index e40e31f5cba..3a389291c42 100644 --- a/integration-tests/tests/api/target/usage.spec.ts +++ b/integration-tests/tests/api/target/usage.spec.ts @@ -2248,7 +2248,7 @@ test.concurrent( `); function collectA() { - client.collectUsage()( + client.collectUsage().finish( { document: queryA, schema, @@ -2261,7 +2261,7 @@ test.concurrent( } function collectB() { - client.collectUsage()( + client.collectUsage().finish( { document: queryB, schema, @@ -2467,7 +2467,7 @@ test.concurrent( `); function collectA() { - client.collectUsage()( + client.collectUsage().finish( { document: queryA, schema, @@ -2639,7 +2639,7 @@ test.concurrent( `); function collectA() { - client.collectUsage()( + client.collectUsage().finish( { document: queryA, schema, @@ -2651,7 +2651,7 @@ test.concurrent( ); } function collectB() { - client.collectUsage()( + client.collectUsage().finish( { document: queryB, schema, @@ -2921,7 +2921,7 @@ test.concurrent( // Now let's make subscription insignificant by making 3 queries - client.collectUsage()( + client.collectUsage().finish( { document: parse('{ a }'), schema, @@ -2931,7 +2931,7 @@ test.concurrent( }, {}, ); - client.collectUsage()( + client.collectUsage().finish( { document: parse('{ a }'), schema, @@ -2941,7 +2941,7 @@ test.concurrent( }, {}, ); - client.collectUsage()( + client.collectUsage().finish( { document: parse('{ a }'), schema, diff --git a/integration-tests/tests/cli/app.spec.ts b/integration-tests/tests/cli/app.spec.ts index b218bfd4ec7..5df359194cf 100644 --- a/integration-tests/tests/cli/app.spec.ts +++ b/integration-tests/tests/cli/app.spec.ts @@ -183,7 +183,7 @@ test('app:retire --force bypasses protection', async () => { }, }); - await client.collectUsage()( + await client.collectUsage().finish( { document: parse(`query { hello }`), schema: buildASTSchema(parse(sdl)), diff --git a/integration-tests/tests/gateway-usage/plugin.spec.ts b/integration-tests/tests/gateway-usage/plugin.spec.ts new file mode 100644 index 00000000000..2f4b745a952 --- /dev/null +++ b/integration-tests/tests/gateway-usage/plugin.spec.ts @@ -0,0 +1,146 @@ +import { AddressInfo } from 'node:net'; +import { parse } from 'graphql'; +import { createLogger, createYoga } from 'graphql-yoga'; +import { ProjectType } from 'testkit/gql/graphql'; +import { initSeed } from 'testkit/seed'; +import { getServiceHost } from 'testkit/utils'; +import { describe, expect, test } from 'vitest'; +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; +import { createHive, useHive } from '@graphql-hive/gateway-usage'; +import { createServer } from '@hive/service-common'; + +async function createSubgraphService() { + const server = await createServer({ + sentryErrorHandler: false, + log: { + requests: false, + level: 'silent', + }, + name: 'products', + }); + + const yoga = createYoga({ + logging: false, + schema: buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! + price: Int + } + `), + resolvers: { + Query: { + product: () => { + return { id: 1, price: 20.2 }; + }, + }, + }, + }), + }); + + server.route({ + // Bind to the Yoga's endpoint to avoid rendering on any path + url: yoga.graphqlEndpoint, + method: ['GET', 'POST', 'OPTIONS'], + handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply), + }); + await server.listen({ + port: 0, + host: '0.0.0.0', + }); + return { + url: 'http://localhost:' + (server.server.address() as AddressInfo).port + yoga.graphqlEndpoint, + [Symbol.asyncDispose]: () => { + server.close(); + }, + }; +} + +describe('GraphQL Hive Plugin', () => { + test('usage data includes subgraph request data', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, waitForRequestsCollected } = await createProject( + ProjectType.Single, + ); + const token = await createTargetAccessToken({}); + const usageAddress = await getServiceHost('usage', 8081); + const client = createHive({ + enabled: true, + token: token.secret, + reporting: false, + usage: true, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + }); + const plugin = useHive(client); + const subgraph = await createSubgraphService(); + const gateway = createGatewayRuntime({ + supergraph: ` + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + directive @join__type(graph: join__Graph!, key: String) repeatable on OBJECT + directive @link(url: String, as: String, for: String) repeatable on SCHEMA + enum join__Graph { PRODUCTS @join__graph(name: "products", url: "${subgraph.url}") } + + type Query @join__type(graph: PRODUCTS) { + product: Product + } + type Product @join__type(graph: PRODUCTS, key: "id") { + id: ID! + price: Int + } + `, + plugins: () => [plugin], + }); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'app-name', + 'x-graphql-client-version': 'app-version', + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + query: ` + { + product { + id + } + } + `, + }), + }); + + const usageCollected = waitForRequestsCollected(1); + const result = await gateway.handle(request); + await expect(result.json()).resolves.toMatchInlineSnapshot(` + { + data: { + product: { + id: 1, + }, + }, + } + `); + await usageCollected; + }); +}); diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index b0c77f1f685..fc65c54413a 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -40,7 +40,7 @@ interface UsageCollector { duration: number; experimental__persistedDocumentHash?: string; /** Optionally send subgraph request information. This provides a deeper level of usage metrics */ - fetches?: OperationSubgraphRequest[]; + fetches?: OperationSubgraphRequest[] | null; }): void; /** collect a long-lived GraphQL request/subscription (subscription operation) */ collectSubscription(args: { @@ -259,7 +259,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ok: errors.length === 0, duration: args.duration, errorsTotal: errors.length, - fetches: args.fetches ?? [], + fetches: args.fetches, }, // TODO: operationHash is ready to accept hashes of persisted operations client: args.experimental__persistedDocumentHash @@ -322,7 +322,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl result, duration, experimental__persistedDocumentHash, - fetches, + fetches: fetches.length > 0 ? fetches : null, }); }, }; @@ -492,7 +492,7 @@ interface CollectedOperation { ok: boolean; duration: number; errorsTotal: number; - fetches: OperationSubgraphRequest[]; + fetches?: OperationSubgraphRequest[] | null; }; persistedDocumentHash?: string; client?: ClientInfo | null; @@ -515,7 +515,7 @@ interface RequestOperation { ok: boolean; duration: number; errorsTotal: number; - fetches: OperationSubgraphRequest[]; + fetches?: OperationSubgraphRequest[] | null; }; persistedDocumentHash?: string; metadata?: { diff --git a/packages/libraries/gateway-usage/package.json b/packages/libraries/gateway-usage/package.json index ac40515dd32..fc0cff1f5b9 100644 --- a/packages/libraries/gateway-usage/package.json +++ b/packages/libraries/gateway-usage/package.json @@ -43,15 +43,15 @@ "check:build": "bob check" }, "peerDependencies": { - "@graphql-hive/gateway": "^2.0.0", + "@envelop/types": "^5.0.0", + "@graphql-hive/gateway-runtime": "^1.0.0 || ^2.0.0", "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { "@graphql-hive/core": "workspace:*" }, "devDependencies": { - "@envelop/types": "5.0.0", - "@graphql-hive/gateway": "^2.0.0" + "@envelop/types": "^5.0.0" }, "publishConfig": { "registry": "https://registry.npmjs.org", diff --git a/packages/libraries/gateway-usage/src/extract-coordinates.ts b/packages/libraries/gateway-usage/src/extract-coordinates.ts index 7a8dbb7a5df..e7fbddc9db7 100644 --- a/packages/libraries/gateway-usage/src/extract-coordinates.ts +++ b/packages/libraries/gateway-usage/src/extract-coordinates.ts @@ -1,14 +1,13 @@ import { - DocumentNode, getNamedType, - GraphQLSchema, - GraphQLType, isInterfaceType, isObjectType, isUnionType, - Kind, - OperationDefinitionNode, - SelectionNode, + type DocumentNode, + type GraphQLSchema, + type GraphQLType, + type OperationDefinitionNode, + type SelectionNode, } from 'graphql'; /** @@ -23,7 +22,7 @@ export function extractSchemaCoordinates( // 1. Find the root operation (Query, Mutation, Subscription) const operation = document.definitions.find( - (def): def is OperationDefinitionNode => def.kind === Kind.OPERATION_DEFINITION, + (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition', ); if (!operation) return counts; @@ -68,7 +67,7 @@ function walkZip( const fields = 'getFields' in namedType ? namedType.getFields() : {}; for (const selection of selections) { - if (selection.kind === Kind.FIELD) { + if (selection.kind === 'Field') { const realFieldName = selection.name.value; const responseKey = selection.alias ? selection.alias.value : realFieldName; @@ -91,10 +90,7 @@ function walkZip( ); } } - } - - // --- INLINE FRAGMENT LOGIC --- - else if (selection.kind === Kind.INLINE_FRAGMENT) { + } else if (selection.kind === 'InlineFragment') { const typeConditionName = selection.typeCondition?.name.value; let matchesType = true; diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts index a36f0854146..182b39b84fd 100644 --- a/packages/libraries/gateway-usage/src/index.ts +++ b/packages/libraries/gateway-usage/src/index.ts @@ -2,13 +2,12 @@ import { responsePathAsArray, type GraphQLError } from 'graphql'; import { autoDisposeSymbol, createHive as createHiveClient, - HiveClient, - HivePluginOptions, isAsyncIterable, isHiveClient, + type HiveClient, + type HivePluginOptions, } from '@graphql-hive/core'; -import type { CollectUsage } from '@graphql-hive/core/src/client/types.js'; -import type { GatewayPlugin } from '@graphql-hive/gateway'; +import { GatewayPlugin } from '@graphql-hive/gateway-runtime'; import { extractSchemaCoordinates } from './extract-coordinates.js'; import { isEntityRequest } from './is-entity-request.js'; import { pathToCoordinate } from './path-to-coordinate.js'; @@ -59,7 +58,7 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Gatewa return { onSubgraphExecute({ executionRequest, subgraphName, subgraph: subgraphSchema }) { const collection = executionRequest.context?.__hiveUsageCollection as - | CollectUsage + | ReturnType | undefined; if (!collection) { diff --git a/packages/libraries/gateway-usage/src/path-to-coordinate.ts b/packages/libraries/gateway-usage/src/path-to-coordinate.ts index 1587071dc02..8f998b9a463 100644 --- a/packages/libraries/gateway-usage/src/path-to-coordinate.ts +++ b/packages/libraries/gateway-usage/src/path-to-coordinate.ts @@ -1,9 +1,9 @@ import { getNamedType, - GraphQLFieldMap, - GraphQLSchema, isInterfaceType, isObjectType, + type GraphQLFieldMap, + type GraphQLSchema, } from 'graphql'; export function pathToCoordinate( diff --git a/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts b/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts deleted file mode 100644 index 271d5c7a3a1..00000000000 --- a/packages/libraries/gateway-usage/tests/gateway-usage.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -// THIS IS GOING TO BE MOVED AND MADE INTO AN INTEGRATION TEST. IT"LL BE BETTER BECAUSE IT WONT REQUIRE MOCKS. - -// import { beforeEach, describe, expect, it, vi } from 'vitest'; -// import { autoDisposeSymbol } from '@graphql-hive/core'; -// import { createGatewayRuntime, GatewayPlugin, GatewayRuntime } from '@graphql-hive/gateway'; -// import * as extractCoordinatesModule from '../src/extract-coordinates.js'; -// import { createHive, useHive } from '../src/index.js'; - -// const MINIMAL_SUPERGRAPH = ` -// schema -// @link(url: "https://specs.apollo.dev/link/v1.0") -// @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) -// { -// query: Query -// } -// directive @join__graph(name: String!, url: String!) on ENUM_VALUE -// directive @join__type(graph: join__Graph!, key: String) repeatable on OBJECT -// directive @link(url: String, as: String, for: String) repeatable on SCHEMA -// enum join__Graph { PRODUCTS @join__graph(name: "products", url: "http://products.svc.local/graphql") } - -// type Query @join__type(graph: PRODUCTS) { -// product: Product -// } -// type Product @join__type(graph: PRODUCTS, key: "id") { -// id: ID! -// price: Int -// } -// `; - -// describe('GraphQL Hive Plugin (Integration Style)', () => { -// beforeEach(() => { -// vi.restoreAllMocks(); -// }); - -// describe('useHive - Initialization & Disposal', () => { -// it('should autoDispose on provided signals', () => { -// const client = createHive({ enabled: false, token: 'dummy-token' }); -// client[autoDisposeSymbol] = ['SIGINT']; - -// const processOnceSpy = vi.spyOn(process, 'once').mockImplementation((_event, cb) => { -// return process; -// }); -// const disposeSpy = vi.spyOn(client, 'dispose').mockResolvedValue(undefined); - -// useHive(client); - -// expect(processOnceSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); - -// // Execute the callback that was registered -// const registeredCallback = processOnceSpy.mock.calls[0][1] as Function; -// registeredCallback(); -// expect(disposeSpy).toHaveBeenCalledOnce(); -// }); -// }); - -// describe('useHive - Plugin Hooks', () => { -// let client: ReturnType; -// let plugin: GatewayPlugin; -// let gateway: GatewayRuntime; - -// beforeEach(() => { -// client = createHive({ enabled: false, token: 'dummy-token' }); -// plugin = useHive(client); -// gateway = createGatewayRuntime({ -// supergraph: MINIMAL_SUPERGRAPH, -// plugins: () => [plugin], -// }); -// }); - -// it('onSchemaChange should call reportSchema on the client', () => { -// const reportSchemaSpy = vi.spyOn(client, 'reportSchema').mockResolvedValue(undefined); -// const fakeSchema = {} as any; - -// plugin.onSchemaChange!({ schema: fakeSchema } as any); - -// expect(reportSchemaSpy).toHaveBeenCalledWith({ schema: fakeSchema }); -// }); - -// it('onSubscribe should call collectSubscriptionUsage on the client', () => { -// const collectSubSpy = vi -// .spyOn(client, 'collectSubscriptionUsage') -// .mockImplementation(() => {}); -// const fakeArgs = { document: {} } as any; - -// plugin.onSubscribe!({ args: fakeArgs } as any); - -// expect(collectSubSpy).toHaveBeenCalledWith({ args: fakeArgs }); -// }); - -// describe('onExecute', () => { -// it('should inject the collection into context and handle sync results', async () => { -// // Spy on the real collectUsage method -// const fakeCollection = { finish: vi.fn() } as any; -// vi.spyOn(client, 'collectUsage').mockReturnValue(fakeCollection); - -// const args = { contextValue: {} } as any; -// const result = { data: { id: '1' } } as any; - -// gateway.handleRequest) -// const onExecuteHook = await plugin.onExecute!({ args } as any); - -// // Context should be mutated -// expect(args.contextValue.__hiveUsageCollection).toBe(fakeCollection); - -// // Handle Sync Execution -// onExecuteHook!.onExecuteDone!({ result } as any); -// expect(fakeCollection.finish).toHaveBeenCalledWith(args, result); -// }); - -// it('should handle async iterable results correctly', async () => { -// const fakeCollection = { finish: vi.fn() } as any; -// vi.spyOn(client, 'collectUsage').mockReturnValue(fakeCollection); - -// const args = { contextValue: {} } as any; -// const onExecuteHook = await plugin.onExecute!({ args } as any); - -// // Create a real async generator to trigger the `isAsyncIterable` path -// async function* mockAsyncIterable() { -// yield { errors: [{ message: 'Stream Error 1' }] }; -// yield { errors: [{ message: 'Stream Error 2' }] }; -// } -// const result = mockAsyncIterable(); - -// const asyncHooks = (await onExecuteHook!.onExecuteDone!({ result } as any))!; - -// // Simulate Yoga streaming hooks -// asyncHooks.onNext!({ result: { errors: [{ message: 'Stream Error 1' }] } } as any); -// asyncHooks.onNext!({ result: { errors: [{ message: 'Stream Error 2' }] } } as any); -// asyncHooks.onEnd!(); - -// // Should collect and format all errors from the stream -// expect(fakeCollection.finish).toHaveBeenCalledWith(args, { -// errors: [{ message: 'Stream Error 1' }, { message: 'Stream Error 2' }], -// }); -// }); -// }); - -// describe('onSubgraphExecute', () => { -// it('should abort early if usage tracking context is missing', () => { -// const hookReturn = plugin.onSubgraphExecute!({ -// executionRequest: { context: {} } as any, -// subgraphName: 'products', -// } as any); -// expect(hookReturn).toBeUndefined(); -// }); - -// it('should finalize subgraph timers and handle errors via finishSubRequest', () => { -// // 1. Setup our fake finishSubRequest callback -// const finishSubRequestSpy = vi.fn(); -// const fakeCollection = { -// subrequest: vi.fn().mockReturnValue(finishSubRequestSpy), -// } as any; - -// // 2. Setup the coordinate extraction spy (so we don't need a massive real GraphQLSchema object) -// vi.spyOn(extractCoordinatesModule, 'extractSchemaCoordinates').mockReturnValue({ -// 'Product.price': 2, -// }); - -// // 3. Trigger the pre-subgraph hook -// const onSubgraphExecuteDone = plugin.onSubgraphExecute!({ -// executionRequest: { -// context: { __hiveUsageCollection: fakeCollection }, -// info: { schema: {} }, -// document: {}, -// rootValue: { __typename: '_Entity' }, // Triggers the ENTITY type path -// } as any, -// subgraphName: 'products', -// } as any) as Function; - -// // Verify the subrequest was started with 'ENTITY' -// expect(fakeCollection.subrequest).toHaveBeenCalledWith({ -// subgraph: 'products', -// type: 'ENTITY', -// paths: undefined, -// }); - -// // 4. Trigger the post-subgraph hook with a simulated error -// const mockResult = { -// data: null, -// errors: [{ path: ['product', 'price'], extensions: { code: 'UNAUTHORIZED' } }], -// }; - -// onSubgraphExecuteDone({ result: mockResult }); - -// // Verify completion handles the 500 status and maps the error formatting -// expect(finishSubRequestSpy).toHaveBeenCalledWith({ -// status: 500, -// fields: { 'Product.price': 2 }, -// errors: [{ coordinate: 'product.price', code: 'UNAUTHORIZED' }], -// }); -// }); - -// it('should return a 200 status when the subgraph yields no errors', () => { -// const finishSubRequestSpy = vi.fn(); -// const fakeCollection = { -// subrequest: vi.fn().mockReturnValue(finishSubRequestSpy), -// } as any; - -// vi.spyOn(extractCoordinatesModule, 'extractSchemaCoordinates').mockReturnValue({}); - -// const onSubgraphExecuteDone = plugin.onSubgraphExecute!({ -// executionRequest: { -// context: { __hiveUsageCollection: fakeCollection }, -// info: { schema: {} }, -// document: {}, -// } as any, -// subgraphName: 'users', -// } as any) as Function; - -// // Simulate successful result -// onSubgraphExecuteDone({ result: { data: { users: [] } } }); - -// expect(finishSubRequestSpy).toHaveBeenCalledWith( -// expect.objectContaining({ -// status: 200, -// errors: undefined, -// }), -// ); -// }); -// }); -// }); -// }); diff --git a/packages/libraries/gateway-usage/tests/index.spec.ts b/packages/libraries/gateway-usage/tests/index.spec.ts new file mode 100644 index 00000000000..5ff7d0b2666 --- /dev/null +++ b/packages/libraries/gateway-usage/tests/index.spec.ts @@ -0,0 +1,21 @@ +import { autoDisposeSymbol } from '@graphql-hive/core'; +import { createHive, useHive } from '../src'; + +describe('Initialization & Disposal', () => { + it('should autoDispose on provided signals', () => { + const client = createHive({ enabled: false, token: 'dummy-token' }); + client[autoDisposeSymbol] = ['SIGINT']; + + const processOnceSpy = vi.spyOn(process, 'once').mockReturnValue(process); + const disposeSpy = vi.spyOn(client, 'dispose').mockResolvedValue(undefined); + + useHive(client); + + expect(processOnceSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + + // Execute the callback that was registered + const registeredCallback = processOnceSpy.mock.calls[0][1] as Function; + registeredCallback(); + expect(disposeSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/libraries/laboratory/package.json b/packages/libraries/laboratory/package.json index 98b36237cd0..a0e020c85ff 100644 --- a/packages/libraries/laboratory/package.json +++ b/packages/libraries/laboratory/package.json @@ -98,7 +98,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", - "graphql": "^16.12.0", + "graphql": "^16.0.0", "lodash": "^4.18.1", "lucide-react": "^0.548.0", "lz-string": "^1.5.0", diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 8598630faf5..859d9c9eade 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -144,7 +144,9 @@ export const action: Action = async exec => { ORDER BY (target, coordinate, code, timestamp, hash, expires_at) -- only store for 24hr because after that, the hourly or daily table will be used TTL least(timestamp + toIntervalHour(24), expires_at) - SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); @@ -202,7 +204,9 @@ export const action: Action = async exec => { ORDER BY (target, coordinate, code, timestamp, hash, expires_at) -- keep for a maximum of 30 days because after that the relative time range will only use the daily calculations TTL least(timestamp + toIntervalDay(30), expires_at) - SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); await exec(` @@ -256,7 +260,9 @@ export const action: Action = async exec => { PRIMARY KEY (target, coordinate, code, timestamp, hash) ORDER BY (target, coordinate, code, timestamp, hash, expires_at) TTL expires_at - SETTINGS index_granularity = 8192, deduplicate_merge_projection_mode = 'rebuild' + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index d36410ccf90..d9386929035 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -76,7 +76,9 @@ export const action: Action = async exec => { ORDER BY (target, coordinate, hash, timestamp, expires_at) -- only store for 24hr because after that, the hourly or daily table will be used TTL least(timestamp + toIntervalHour(24), expires_at) - SETTINGS index_granularity = 8192 + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); @@ -127,7 +129,9 @@ export const action: Action = async exec => { PRIMARY KEY (target, coordinate, hash, timestamp) ORDER BY (target, coordinate, hash, timestamp, expires_at) TTL least(timestamp + toIntervalDay(30), expires_at) - SETTINGS index_granularity = 8192 + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); @@ -175,7 +179,9 @@ export const action: Action = async exec => { PRIMARY KEY (target, coordinate, hash, timestamp) ORDER BY (target, coordinate, hash, timestamp, expires_at) TTL expires_at - SETTINGS index_granularity = 8192 + SETTINGS + index_granularity = 8192 + , deduplicate_merge_projection_mode = 'rebuild' ; `); diff --git a/packages/services/usage/src/metric-helper.ts b/packages/services/usage/src/metric-helper.ts index 802a405cfa2..6e5bd48c21e 100644 --- a/packages/services/usage/src/metric-helper.ts +++ b/packages/services/usage/src/metric-helper.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { httpRequestHandlerDuration, parseReportDuration } from './metrics'; -export function measureParsing(fn: () => T, version: 'v1' | 'v2'): T { +export function measureParsing(fn: () => T, version: 'v1' | 'v2' | 'v3'): T { const stop = parseReportDuration.startTimer({ version }); try { const result = fn(); diff --git a/packages/services/usage/src/usage-processor-2.ts b/packages/services/usage/src/usage-processor-2.ts index a670643d62a..2d3061eed4c 100644 --- a/packages/services/usage/src/usage-processor-2.ts +++ b/packages/services/usage/src/usage-processor-2.ts @@ -262,6 +262,55 @@ const OperationMapRecordSchema = tb.Object( type OperationMapRecord = tb.Static; +const SubgraphRequestSchema = tb.Object( + { + /** Delta start time from "timestamp" */ + start: tb.Integer({ + minimum: 0, + maximum: Math.pow(2, 63), + }), + + /** How long the request took */ + duration: tb.Integer({ + minimum: 0, + maximum: Math.pow(2, 63), + }), + + /** HTTP Status Code */ + status: tb.Integer({ + minimum: 100, + maximum: 599, + }), + + /** Number of times the field has been requested. Regardless of success or failure */ + fields: tb.Record(tb.String(), tb.Number()), + + /** Error code for a coordinate, with a code returned from the graphql extensions */ + errors: tb.Optional( + tb.Array(tb.Object({ coordinate: tb.String(), code: tb.Optional(tb.String()) })), + ), + + /** Which subgraph resolved this path */ + subgraph: tb.String(), + + /** + * If this is an entity request, then this is the coordinate in the original operation that is being resolved. + * If undefined, then the path is assumed to be 'Query'. + */ + paths: tb.Union([tb.String(), tb.Array(tb.String(), { minItems: 1 })]), + + /** + * What type of request this is. Root is if resolving a root query/mutation field. Entity is + * if resolving an entity type in federation. + * */ + type: tb.Union([tb.Literal('ROOT'), tb.Literal('ENTITY')]), + }, + { + title: 'SubgraphRequest', + additionalProperties: false, + }, +); + const ExecutionSchema = tb.Type.Object( { ok: tb.Type.Boolean(), @@ -277,6 +326,7 @@ const ExecutionSchema = tb.Type.Object( minimum: 0, maximum: Math.pow(2, 16) - 1, }), + fetches: OptionalAndNullable(tb.Array(SubgraphRequestSchema)), }, { title: 'Execution', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce3bc005ece..518c5d06816 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,7 +277,7 @@ importers: devDependencies: '@graphql-hive/gateway': specifier: 2.7.2 - version: 2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(zod@4.3.6) + version: 2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(zod@4.3.6) '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -317,6 +317,12 @@ importers: '@graphql-hive/core': specifier: workspace:* version: link:../packages/libraries/core/dist + '@graphql-hive/gateway-runtime': + specifier: ^1.0.0 || ^2.0.0 + version: 2.9.3(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-usage': + specifier: workspace:* + version: link:../packages/libraries/gateway-usage/dist '@graphql-typed-document-node/core': specifier: 3.2.0 version: 3.2.0(graphql@16.9.0) @@ -653,16 +659,16 @@ importers: '@graphql-hive/core': specifier: workspace:* version: link:../core/dist + '@graphql-hive/gateway-runtime': + specifier: ^1.0.0 || ^2.0.0 + version: 2.9.3(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) graphql: specifier: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - version: 16.12.0 + version: 16.9.0 devDependencies: '@envelop/types': - specifier: 5.0.0 + specifier: ^5.0.0 version: 5.0.0 - '@graphql-hive/gateway': - specifier: ^2.0.0 - version: 2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(zod@4.3.6) publishDirectory: dist packages/libraries/laboratory: @@ -672,7 +678,7 @@ importers: version: 1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@graphql-tools/url-loader': specifier: ^9.1.0 - version: 9.1.0(@types/node@24.12.2)(graphql@16.12.0) + version: 9.1.0(@types/node@24.12.2)(graphql@16.9.0) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -843,8 +849,8 @@ importers: specifier: ^16.5.0 version: 16.5.0 graphql: - specifier: ^16.12.0 - version: 16.12.0 + specifier: ^16.0.0 + version: 16.9.0 lodash: specifier: ^4.17.23 version: 4.18.1 @@ -859,7 +865,7 @@ importers: version: 0.52.2 monaco-graphql: specifier: ^1.7.3 - version: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1) + version: 1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1) monacopilot: specifier: ^1.2.12 version: 1.2.12(monaco-editor@0.52.2) @@ -892,7 +898,7 @@ importers: version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) subscriptions-transport-ws: specifier: ^0.11.0 - version: 0.11.0(graphql@16.12.0) + version: 0.11.0(graphql@16.9.0) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -949,7 +955,7 @@ importers: version: 0.16.6(typescript@5.7.3) graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.12.0) + version: 5.13.3(graphql@16.14.0) ioredis: specifier: ^5.0.0 version: 5.8.2 @@ -968,7 +974,7 @@ importers: version: link:../laboratory graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.12.0) + version: 5.13.3(graphql@16.14.0) publishDirectory: dist packages/libraries/yoga: @@ -1712,7 +1718,7 @@ importers: version: 1.1.0(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.4.26 - version: 1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + version: 1.4.26(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -13614,8 +13620,8 @@ packages: peerDependencies: graphql: ^15.2.0 || ^16.0.0 - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} graphql@16.9.0: @@ -19125,13 +19131,13 @@ snapshots: '@apollo/utils.logger': 2.0.0 graphql: 16.9.0 - '@apollo/server-gateway-interface@2.0.0(graphql@16.12.0)': + '@apollo/server-gateway-interface@2.0.0(graphql@16.14.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 '@apollo/utils.fetcher': 3.1.0 '@apollo/utils.keyvaluecache': 4.0.0 '@apollo/utils.logger': 3.0.0 - graphql: 16.12.0 + graphql: 16.14.0 '@apollo/server-gateway-interface@2.0.0(graphql@16.9.0)': dependencies: @@ -19225,9 +19231,9 @@ snapshots: '@apollo/utils.isnodelike': 3.0.0 sha.js: 2.4.11 - '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.12.0)': + '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.9.0)': dependencies: @@ -19262,25 +19268,25 @@ snapshots: '@apollo/utils.logger@3.0.0': {} - '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.12.0)': + '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.removealiases@2.0.1(graphql@16.12.0)': + '@apollo/utils.removealiases@2.0.1(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 '@apollo/utils.removealiases@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.sortast@2.0.1(graphql@16.12.0)': + '@apollo/utils.sortast@2.0.1(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 lodash.sortby: 4.7.0 '@apollo/utils.sortast@2.0.1(graphql@16.9.0)': @@ -19288,23 +19294,23 @@ snapshots: graphql: 16.9.0 lodash.sortby: 4.7.0 - '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.12.0)': + '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.usagereporting@2.1.0(graphql@16.12.0)': + '@apollo/utils.usagereporting@2.1.0(graphql@16.14.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@16.12.0) - '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@16.12.0) - '@apollo/utils.removealiases': 2.0.1(graphql@16.12.0) - '@apollo/utils.sortast': 2.0.1(graphql@16.12.0) - '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@16.12.0) - graphql: 16.12.0 + '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@16.14.0) + '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@16.14.0) + '@apollo/utils.removealiases': 2.0.1(graphql@16.14.0) + '@apollo/utils.sortast': 2.0.1(graphql@16.14.0) + '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@16.14.0) + graphql: 16.14.0 '@apollo/utils.usagereporting@2.1.0(graphql@16.9.0)': dependencies: @@ -21278,10 +21284,10 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': @@ -21290,11 +21296,11 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': @@ -21304,14 +21310,14 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/extended-validation': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@envelop/extended-validation': 7.0.0(@envelop/core@5.5.1)(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': @@ -21344,11 +21350,11 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@envelop/on-resolve@7.1.1(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/on-resolve@7.1.1(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 '@envelop/on-resolve@7.1.1(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: @@ -21356,22 +21362,22 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 graphql: 16.9.0 - '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3)': + '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.14.0)(prom-client@15.1.3)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - graphql: 16.12.0 + '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.14.0) + graphql: 16.14.0 prom-client: 15.1.3 tslib: 2.8.1 - '@envelop/rate-limiter@10.0.1(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/rate-limiter@10.0.1(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@types/picomatch': 4.0.2 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 lodash.get: 4.4.2 ms: 2.1.3 picomatch: 4.0.4 @@ -21398,14 +21404,14 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - '@envelop/response-cache@9.1.1(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/response-cache@9.1.1(@envelop/core@5.5.1)(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 fast-json-stable-stringify: 2.1.0 - graphql: 16.12.0 + graphql: 16.14.0 lru-cache: 11.0.2 tslib: 2.8.1 @@ -21519,41 +21525,41 @@ snapshots: '@escape.tech/graphql-armor-block-field-suggestions@3.0.1': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-max-aliases@2.6.2': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-types': 0.7.0 '@escape.tech/graphql-armor-max-depth@2.4.2': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-types': 0.7.0 '@escape.tech/graphql-armor-max-directives@2.3.1': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-types': 0.7.0 '@escape.tech/graphql-armor-max-tokens@2.5.1': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-types': 0.7.0 '@escape.tech/graphql-armor-types@0.7.0': dependencies: - graphql: 16.12.0 + graphql: 16.9.0 optional: true '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))': @@ -21836,7 +21842,7 @@ snapshots: dependencies: '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 graphql: 16.9.0 - meros: 1.2.1(@types/node@25.5.0) + meros: 1.3.2(@types/node@25.5.0) optionalDependencies: graphql-ws: 5.16.1(graphql@16.9.0) transitivePeerDependencies: @@ -22102,15 +22108,15 @@ snapshots: - supports-color - utf-8-validate - '@graphql-hive/core@0.21.0(graphql@16.12.0)(pino@10.3.0)': + '@graphql-hive/core@0.21.0(graphql@16.14.0)(pino@10.3.0)': dependencies: '@graphql-hive/logger': 1.1.0(pino@10.3.0) '@graphql-hive/signal': 2.0.0 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) '@whatwg-node/fetch': 0.10.13 async-retry: 1.3.3 events: 3.3.0 - graphql: 16.12.0 + graphql: 16.14.0 js-md5: 0.8.3 lodash.sortby: 4.7.0 tiny-lru: 11.4.7 @@ -22136,45 +22142,45 @@ snapshots: - pino - winston - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': + '@graphql-hive/gateway-runtime@2.9.3(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.14.0) + '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.14.0) '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/core': 0.21.0(graphql@16.14.0)(pino@10.3.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) + '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.14.0) + '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.14.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) + '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) + '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.0(graphql@16.14.0)) + '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) '@types/node': 25.5.0 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server': 0.10.17 '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-ws: 6.0.6(graphql@16.14.0)(ws@8.18.0) + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@fastify/websocket' @@ -22187,45 +22193,45 @@ snapshots: - winston - ws - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': + '@graphql-hive/gateway-runtime@2.9.3(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.9.0) + '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.9.0) '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/core': 0.21.0(graphql@16.9.0)(pino@10.3.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.12.0) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) + '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0)(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) + '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) + '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.9.0) + '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.9.0) + '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0) + '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.0(graphql@16.9.0)) + '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0) '@types/node': 25.5.0 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server': 0.10.17 '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.9.0 + graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) + graphql-yoga: 5.21.0(graphql@16.9.0) tslib: 2.8.1 transitivePeerDependencies: - '@fastify/websocket' @@ -22289,43 +22295,43 @@ snapshots: - winston - ws - '@graphql-hive/gateway@2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(zod@4.3.6)': + '@graphql-hive/gateway@2.7.2(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(ioredis@5.10.1)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(pino@10.3.0)(prom-client@15.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(zod@4.3.6)': dependencies: '@commander-js/extra-typings': 14.0.0(commander@14.0.2) '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-block-field-suggestions': 3.0.1 '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/importer': 2.0.0 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/plugin-aws-sigv4': 2.0.46(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-hive/plugin-opentelemetry': 1.4.26(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/plugin-aws-sigv4': 2.0.46(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-hive/plugin-opentelemetry': 1.4.26(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) - '@graphql-hive/render-laboratory': 0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6) + '@graphql-hive/render-laboratory': 0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(tslib@2.8.1)(zod@4.3.6) '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/cache-cfw-kv': 0.105.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/cache-localforage': 0.105.37(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/cache-redis': 0.105.23(@types/ioredis-mock@8.2.5)(graphql@16.12.0) - '@graphql-mesh/cache-upstash-redis': 0.1.32(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-http-cache': 0.105.38(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-jit': 0.2.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-jwt-auth': 2.0.11(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-prometheus': 2.1.44(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) - '@graphql-mesh/plugin-rate-limit': 0.106.15(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/plugin-snapshot': 0.104.37(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/transport-http': 1.1.0(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/transport-http-callback': 1.0.20(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/transport-ws': 2.0.20(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/code-file-loader': 8.1.26(graphql@16.12.0) - '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.12.0) - '@graphql-tools/load': 8.1.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.21.0(graphql@16.12.0)) + '@graphql-mesh/cache-cfw-kv': 0.105.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/cache-localforage': 0.105.37(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/cache-redis': 0.105.23(@types/ioredis-mock@8.2.5)(graphql@16.14.0) + '@graphql-mesh/cache-upstash-redis': 0.1.32(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-http-cache': 0.105.38(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-jit': 0.2.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-jwt-auth': 2.0.11(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-prometheus': 2.1.44(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) + '@graphql-mesh/plugin-rate-limit': 0.106.15(@envelop/core@5.5.1)(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-snapshot': 0.104.37(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/transport-http': 1.1.0(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/transport-http-callback': 1.0.20(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/transport-ws': 2.0.20(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/code-file-loader': 8.1.26(graphql@16.14.0) + '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.14.0) + '@graphql-tools/load': 8.1.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.21.0(graphql@16.14.0)) '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.217.0 '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) @@ -22340,9 +22346,9 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) '@whatwg-node/server': 0.10.17 commander: 14.0.2 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-ws: 6.0.6(graphql@16.14.0)(ws@8.18.0) + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -22375,10 +22381,10 @@ snapshots: '@graphql-hive/importer@2.0.0': {} - '@graphql-hive/laboratory@0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6)': + '@graphql-hive/laboratory@0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(tslib@2.8.1)(zod@4.3.6)': dependencies: '@base-ui/react': 1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@graphql-tools/url-loader': 9.1.0(@types/node@24.12.2)(graphql@16.12.0) + '@graphql-tools/url-loader': 9.1.0(@types/node@24.12.2)(graphql@16.14.0) '@tanstack/react-form': 1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) date-fns: 4.1.0 lucide-react: 0.548.0(react@18.3.1) @@ -22387,7 +22393,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-zoom-pan-pinch: 3.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - subscriptions-transport-ws: 0.11.0(graphql@16.12.0) + subscriptions-transport-ws: 0.11.0(graphql@16.14.0) tslib: 2.8.1 uuid: 14.0.0 zod: 4.3.6 @@ -22406,13 +22412,13 @@ snapshots: optionalDependencies: pino: 10.3.0 - '@graphql-hive/plugin-aws-sigv4@2.0.46(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-hive/plugin-aws-sigv4@2.0.46(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@aws-sdk/client-sts': 3.1045.0 - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) '@whatwg-node/promise-helpers': 1.3.2 aws4: 1.13.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22423,54 +22429,16 @@ snapshots: - pino - winston - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/auto-instrumentations-node': 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)) - '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-node': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - supports-color - - uWebSockets.js - - winston - - ws - - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': + '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/core': 0.21.0(graphql@16.14.0)(pino@10.3.0) + '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.217.0 '@opentelemetry/auto-instrumentations-node': 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)) @@ -22485,7 +22453,7 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@fastify/websocket' @@ -22553,10 +22521,10 @@ snapshots: optionalDependencies: ioredis: 5.8.2 - '@graphql-hive/render-laboratory@0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6)': + '@graphql-hive/render-laboratory@0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(tslib@2.8.1)(zod@4.3.6)': dependencies: - '@graphql-hive/laboratory': 0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6) - graphql-yoga: 5.13.3(graphql@16.12.0) + '@graphql-hive/laboratory': 0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.14.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.14.0))(tslib@2.8.1)(zod@4.3.6) + graphql-yoga: 5.13.3(graphql@16.14.0) transitivePeerDependencies: - '@fastify/websocket' - '@tanstack/react-form' @@ -22581,13 +22549,13 @@ snapshots: '@graphql-hive/signal@2.0.0': {} - '@graphql-hive/yoga@0.48.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0)': + '@graphql-hive/yoga@0.48.0(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(pino@10.3.0)': dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/core': 0.21.0(graphql@16.14.0)(pino@10.3.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) transitivePeerDependencies: - '@logtape/logtape' - pino @@ -22836,48 +22804,48 @@ snapshots: - '@graphql-inspector/loaders' - yargs - '@graphql-mesh/cache-cfw-kv@0.105.36(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/cache-cfw-kv@0.105.36(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-inmemory-lru@0.8.37(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/cache-inmemory-lru@0.8.37(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-localforage@0.105.37(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/cache-localforage@0.105.37(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/cache-inmemory-lru': 0.8.37(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - graphql: 16.12.0 + '@graphql-mesh/cache-inmemory-lru': 0.8.37(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + graphql: 16.14.0 localforage: 1.10.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-redis@0.105.23(@types/ioredis-mock@8.2.5)(graphql@16.12.0)': + '@graphql-mesh/cache-redis@0.105.23(@types/ioredis-mock@8.2.5)(graphql@16.14.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) '@opentelemetry/api': 1.9.1 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.14.0 ioredis: 5.10.1 ioredis-mock: 8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.10.1) tslib: 2.8.1 @@ -22886,22 +22854,22 @@ snapshots: - '@types/ioredis-mock' - supports-color - '@graphql-mesh/cache-upstash-redis@0.1.32(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/cache-upstash-redis@0.1.32(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) '@upstash/redis': 1.38.0 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cross-helpers@0.4.14(graphql@16.12.0)': + '@graphql-mesh/cross-helpers@0.4.14(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 path-browserify: 1.0.1 '@graphql-mesh/cross-helpers@0.4.14(graphql@16.9.0)': @@ -22910,28 +22878,28 @@ snapshots: graphql: 16.9.0 path-browserify: 1.0.1 - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.10.3(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/federation': 4.4.3(@types/node@24.12.2)(graphql@16.14.0) + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.14.0) + '@graphql-tools/stitching-directives': 4.0.21(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22941,28 +22909,28 @@ snapshots: - pino - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.14.0) + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.14.0) + '@graphql-tools/stitching-directives': 4.0.21(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22972,28 +22940,28 @@ snapshots: - pino - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/batch-execute': 10.0.8(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) + '@graphql-tools/executor': 1.5.0(graphql@16.9.0) + '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.9.0) + '@graphql-tools/merge': 9.1.5(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.9.0) + '@graphql-tools/stitching-directives': 4.0.21(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.9.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.9.0 + graphql-yoga: 5.21.0(graphql@16.9.0) tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23034,29 +23002,29 @@ snapshots: - pino - winston - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)': + '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.9.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -23076,37 +23044,37 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-http-cache@0.105.38(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-http-cache@0.105.38(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 http-cache-semantics: 4.1.1 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jit@0.2.36(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-jit@0.2.36(graphql@16.14.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 - graphql-jit: 0.8.7(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + graphql-jit: 0.8.7(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jwt-auth@2.0.11(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-jwt-auth@2.0.11(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -23114,16 +23082,16 @@ snapshots: - ioredis - supports-color - '@graphql-mesh/plugin-prometheus@2.1.44(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': + '@graphql-mesh/plugin-prometheus@2.1.44(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': dependencies: - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3) - graphql: 16.12.0 + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(prom-client@15.1.3) + graphql: 16.14.0 prom-client: 15.1.3 tslib: 2.8.1 transitivePeerDependencies: @@ -23139,55 +23107,55 @@ snapshots: - winston - ws - '@graphql-mesh/plugin-rate-limit@0.106.15(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-rate-limit@0.106.15(@envelop/core@5.5.1)(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@envelop/rate-limiter': 10.0.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@envelop/rate-limiter': 10.0.1(@envelop/core@5.5.1)(graphql@16.14.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@envelop/core' - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)': + '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.14.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) + '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.14.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 cache-control-parser: 2.2.0 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) + '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.9.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.9.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 cache-control-parser: 2.2.0 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.9.0 + graphql-yoga: 5.21.0(graphql@16.9.0) tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -23212,24 +23180,24 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-snapshot@0.104.37(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/plugin-snapshot@0.104.37(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) '@whatwg-node/fetch': 0.10.13 - graphql: 16.12.0 + graphql: 16.14.0 minimatch: 10.2.4 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/string-interpolation@0.5.16(graphql@16.12.0)': + '@graphql-mesh/string-interpolation@0.5.16(graphql@16.14.0)': dependencies: dayjs: 1.11.20 - graphql: 16.12.0 + graphql: 16.14.0 json-pointer: 0.6.2 lodash.get: 4.4.2 tslib: 2.8.1 @@ -23242,17 +23210,17 @@ snapshots: lodash.get: 4.4.2 tslib: 2.8.1 - '@graphql-mesh/transport-common@1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/transport-common@1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@graphql-hive/logger': 1.1.0(pino@10.3.0) '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23261,17 +23229,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-common@1.0.16(graphql@16.12.0)(pino@10.3.0)': + '@graphql-mesh/transport-common@1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/executor': 1.5.0(graphql@16.9.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23299,20 +23267,20 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http-callback@1.0.20(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/transport-http-callback@1.0.20(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23321,17 +23289,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http@1.1.0(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/transport-http@1.1.0(@types/node@24.12.2)(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/executor-http': 3.3.0(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/executor-http': 3.3.0(@types/node@24.12.2)(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23341,17 +23309,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-ws@2.0.20(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)': + '@graphql-mesh/transport-ws@2.0.20(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 + graphql-ws: 6.0.6(graphql@16.14.0)(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -23366,31 +23334,31 @@ snapshots: - utf-8-validate - winston - '@graphql-mesh/types@0.104.28(graphql@16.12.0)': + '@graphql-mesh/types@0.104.28(graphql@16.14.0)(ioredis@5.10.1)': dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/types@0.104.28(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/types@0.104.28(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -23411,21 +23379,21 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)': + '@graphql-mesh/utils@0.104.36(graphql@16.14.0)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.0) + '@graphql-mesh/types': 0.104.28(graphql@16.14.0)(ioredis@5.10.1) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 dset: 3.1.4 - graphql: 16.12.0 + graphql: 16.14.0 js-yaml: 4.1.1 lodash.get: 4.4.2 lodash.topath: 4.5.2 @@ -23435,21 +23403,21 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)(ioredis@5.10.1)': + '@graphql-mesh/utils@0.104.36(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0)(ioredis@5.10.1) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.9.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.9.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 dset: 3.1.4 - graphql: 16.12.0 + graphql: 16.9.0 js-yaml: 4.1.1 lodash.get: 4.4.2 lodash.topath: 4.5.2 @@ -23491,13 +23459,13 @@ snapshots: sync-fetch: 0.6.0-2 tslib: 2.8.1 - '@graphql-tools/batch-delegate@10.0.22(graphql@16.12.0)': + '@graphql-tools/batch-delegate@10.0.22(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 dataloader: 2.2.3 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/batch-delegate@10.0.22(graphql@16.9.0)': @@ -23518,12 +23486,12 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/batch-execute@10.0.8(graphql@16.12.0)': + '@graphql-tools/batch-execute@10.0.8(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 dataloader: 2.2.3 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/batch-execute@10.0.8(graphql@16.9.0)': @@ -23603,12 +23571,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/code-file-loader@8.1.26(graphql@16.12.0)': + '@graphql-tools/code-file-loader@8.1.26(graphql@16.14.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) globby: 11.1.0 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 unixify: 1.0.0 transitivePeerDependencies: @@ -23639,16 +23607,16 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/delegate@12.0.16(graphql@16.12.0)': + '@graphql-tools/delegate@12.0.16(graphql@16.14.0)': dependencies: - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/promise-helpers': 1.3.2 dataloader: 2.2.3 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/delegate@12.0.16(graphql@16.9.0)': @@ -23698,11 +23666,11 @@ snapshots: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 - '@graphql-tools/executor-common@1.0.6(graphql@16.12.0)': + '@graphql-tools/executor-common@1.0.6(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 '@graphql-tools/executor-common@1.0.6(graphql@16.9.0)': dependencies: @@ -23769,13 +23737,30 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.12.0)': + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.14.0)': dependencies: - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + graphql: 16.14.0 + graphql-ws: 6.0.6(graphql@16.14.0)(ws@8.18.0) + isows: 1.0.7(ws@8.18.0) + tslib: 2.8.1 + ws: 8.18.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - uWebSockets.js + - utf-8-validate + + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.9.0)': + dependencies: + '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.9.0 + graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) isows: 1.0.7(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 @@ -23843,46 +23828,61 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.2.1(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/executor-http@3.2.1(@types/node@24.12.2)(graphql@16.14.0)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.0 + meros: 1.3.2(@types/node@24.12.2) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-http@3.2.1(@types/node@24.12.2)(graphql@16.9.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 meros: 1.3.2(@types/node@24.12.2) tslib: 2.8.1 transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.3.0(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/executor-http@3.3.0(@types/node@24.12.2)(graphql@16.14.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 meros: 1.3.2(@types/node@24.12.2) tslib: 2.8.1 transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.3.0(@types/node@25.5.0)(graphql@16.12.0)': + '@graphql-tools/executor-http@3.3.0(@types/node@25.5.0)(graphql@16.14.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.6(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 meros: 1.3.2(@types/node@25.5.0) tslib: 2.8.1 transitivePeerDependencies: @@ -23939,11 +23939,23 @@ snapshots: - bufferutil - utf-8-validate - '@graphql-tools/executor-legacy-ws@1.1.26(graphql@16.12.0)': + '@graphql-tools/executor-legacy-ws@1.1.26(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@types/ws': 8.5.3 - graphql: 16.12.0 + graphql: 16.14.0 + isomorphic-ws: 5.0.0(ws@8.18.0) + tslib: 2.8.1 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor-legacy-ws@1.1.26(graphql@16.9.0)': + dependencies: + '@graphql-tools/utils': 11.1.0(graphql@16.9.0) + '@types/ws': 8.5.3 + graphql: 16.9.0 isomorphic-ws: 5.0.0(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 @@ -23970,14 +23982,14 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/executor@1.5.0(graphql@16.12.0)': + '@graphql-tools/executor@1.5.0(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/executor@1.5.0(graphql@16.9.0)': @@ -23990,42 +24002,42 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/federation@4.4.3(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/federation@4.4.3(@types/node@24.12.2)(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/executor-http': 3.3.0(@types/node@24.12.2)(graphql@16.14.0) + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@graphql-yoga/typed-event-target': 3.0.2 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/events': 0.1.2 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@types/node' - '@graphql-tools/federation@4.4.3(@types/node@25.5.0)(graphql@16.12.0)': + '@graphql-tools/federation@4.4.3(@types/node@25.5.0)(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.14.0) + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/stitch': 10.1.19(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@graphql-yoga/typed-event-target': 3.0.2 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/events': 0.1.2 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - '@types/node' @@ -24134,12 +24146,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.12.0)': + '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.14.0)': dependencies: - '@graphql-tools/import': 7.1.7(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/import': 7.1.7(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) globby: 11.1.0 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 unixify: 1.0.0 transitivePeerDependencies: @@ -24221,15 +24233,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.12.0)': + '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.14.0)': dependencies: '@babel/core': 7.28.5 '@babel/parser': 7.29.0 '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -24271,11 +24283,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/import@7.1.7(graphql@16.12.0)': + '@graphql-tools/import@7.1.7(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@theguild/federation-composition': 0.20.2(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + '@theguild/federation-composition': 0.20.2(graphql@16.14.0) + graphql: 16.14.0 resolve-from: 5.0.0 tslib: 2.8.1 transitivePeerDependencies: @@ -24339,11 +24351,11 @@ snapshots: p-limit: 3.1.0 tslib: 2.8.1 - '@graphql-tools/load@8.1.6(graphql@16.12.0)': + '@graphql-tools/load@8.1.6(graphql@16.14.0)': dependencies: - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + graphql: 16.14.0 p-limit: 3.1.0 tslib: 2.8.1 @@ -24353,10 +24365,10 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/merge@9.1.1(graphql@16.12.0)': + '@graphql-tools/merge@9.1.1(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/merge@9.1.1(graphql@16.9.0)': @@ -24365,10 +24377,10 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/merge@9.1.5(graphql@16.12.0)': + '@graphql-tools/merge@9.1.5(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/merge@9.1.5(graphql@16.9.0)': @@ -24397,11 +24409,11 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/schema@10.0.25(graphql@16.12.0)': + '@graphql-tools/schema@10.0.25(graphql@16.14.0)': dependencies: - '@graphql-tools/merge': 9.1.1(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/merge': 9.1.1(graphql@16.14.0) + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/schema@10.0.25(graphql@16.9.0)': @@ -24411,11 +24423,11 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/schema@10.0.29(graphql@16.12.0)': + '@graphql-tools/schema@10.0.29(graphql@16.14.0)': dependencies: - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/schema@10.0.29(graphql@16.9.0)': @@ -24433,17 +24445,17 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/stitch@10.1.19(graphql@16.12.0)': + '@graphql-tools/stitch@10.1.19(graphql@16.14.0)': dependencies: - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) + '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/merge': 9.1.5(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.15(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/stitch@10.1.19(graphql@16.9.0)': @@ -24479,11 +24491,11 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/stitching-directives@4.0.21(graphql@16.12.0)': + '@graphql-tools/stitching-directives@4.0.21(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/stitching-directives@4.0.21(graphql@16.9.0)': @@ -24604,17 +24616,17 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/url-loader@9.1.0(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/url-loader@9.1.0(@types/node@24.12.2)(graphql@16.14.0)': dependencies: - '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.12.0) - '@graphql-tools/executor-http': 3.2.1(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/executor-legacy-ws': 1.1.26(graphql@16.12.0) - '@graphql-tools/utils': 11.0.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.14.0) + '@graphql-tools/executor-http': 3.2.1(@types/node@24.12.2)(graphql@16.14.0) + '@graphql-tools/executor-legacy-ws': 1.1.26(graphql@16.14.0) + '@graphql-tools/utils': 11.0.0(graphql@16.14.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.14.0) '@types/ws': 8.5.3 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 isomorphic-ws: 5.0.0(ws@8.18.0) sync-fetch: 0.6.0 tslib: 2.8.1 @@ -24627,12 +24639,35 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/utils@10.11.0(graphql@16.12.0)': + '@graphql-tools/url-loader@9.1.0(@types/node@24.12.2)(graphql@16.9.0)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.9.0) + '@graphql-tools/executor-http': 3.2.1(@types/node@24.12.2)(graphql@16.9.0) + '@graphql-tools/executor-legacy-ws': 1.1.26(graphql@16.9.0) + '@graphql-tools/utils': 11.0.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.9.0) + '@types/ws': 8.5.3 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.9.0 + isomorphic-ws: 5.0.0(ws@8.18.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.18.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - uWebSockets.js + - utf-8-validate + + '@graphql-tools/utils@10.11.0(graphql@16.14.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/utils@10.11.0(graphql@16.9.0)': @@ -24660,13 +24695,13 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/utils@10.9.1(graphql@16.12.0)': + '@graphql-tools/utils@10.9.1(graphql@16.14.0)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.1 cross-inspect: 1.0.1 dset: 3.1.4 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/utils@10.9.1(graphql@16.9.0)': @@ -24678,20 +24713,28 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/utils@11.0.0(graphql@16.12.0)': + '@graphql-tools/utils@11.0.0(graphql@16.14.0)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 - '@graphql-tools/utils@11.1.0(graphql@16.12.0)': + '@graphql-tools/utils@11.0.0(graphql@16.9.0)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.9.0 + tslib: 2.8.1 + + '@graphql-tools/utils@11.1.0(graphql@16.14.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/utils@11.1.0(graphql@16.9.0)': @@ -24726,13 +24769,13 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/wrap@11.1.15(graphql@16.12.0)': + '@graphql-tools/wrap@11.1.15(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/utils': 11.1.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 tslib: 2.8.1 '@graphql-tools/wrap@11.1.15(graphql@16.9.0)': @@ -24744,13 +24787,22 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/wrap@11.1.2(graphql@16.12.0)': + '@graphql-tools/wrap@11.1.2(graphql@16.14.0)': dependencies: - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.16(graphql@16.14.0) + '@graphql-tools/schema': 10.0.29(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.14.0 + tslib: 2.8.1 + + '@graphql-tools/wrap@11.1.2(graphql@16.9.0)': + dependencies: + '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) + '@graphql-tools/schema': 10.0.29(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.9.0 tslib: 2.8.1 '@graphql-tools/wrap@9.4.2(graphql@16.9.0)': @@ -24762,9 +24814,9 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)': dependencies: - graphql: 16.12.0 + graphql: 16.14.0 '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': dependencies: @@ -24774,12 +24826,12 @@ snapshots: dependencies: tslib: 2.8.1 - '@graphql-yoga/plugin-apollo-inline-trace@3.21.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-apollo-inline-trace@3.21.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 - '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + '@envelop/on-resolve': 7.1.1(@envelop/core@5.5.1)(graphql@16.14.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@envelop/core' @@ -24794,16 +24846,16 @@ snapshots: transitivePeerDependencies: - '@envelop/core' - '@graphql-yoga/plugin-apollo-usage-report@0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-apollo-usage-report@0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: - '@apollo/server-gateway-interface': 2.0.0(graphql@16.12.0) + '@apollo/server-gateway-interface': 2.0.0(graphql@16.14.0) '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.usagereporting': 2.1.0(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-inline-trace': 3.21.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0) + '@apollo/utils.usagereporting': 2.1.0(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) + '@graphql-yoga/plugin-apollo-inline-trace': 3.21.0(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) tslib: 2.8.1 transitivePeerDependencies: - '@envelop/core' @@ -24822,19 +24874,19 @@ snapshots: transitivePeerDependencies: - '@envelop/core' - '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))': + '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))': dependencies: - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql-yoga: 5.21.0(graphql@16.14.0) '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.21.0(graphql@16.9.0))': dependencies: graphql-yoga: 5.21.0(graphql@16.9.0) - '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0)': dependencies: @@ -24859,23 +24911,23 @@ snapshots: graphql-sse: 2.6.0(graphql@16.9.0) graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0)': dependencies: @@ -24889,11 +24941,11 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3)': + '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)(prom-client@15.1.3)': dependencies: - '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3) - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.14.0)(prom-client@15.1.3) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) prom-client: 15.1.3 transitivePeerDependencies: - '@envelop/core' @@ -24906,13 +24958,13 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-response-cache@3.23.0(graphql-yoga@5.21.0(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-response-cache@3.23.0(graphql-yoga@5.21.0(graphql@16.14.0))(graphql@16.14.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.14.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql: 16.14.0 + graphql-yoga: 5.21.0(graphql@16.14.0) '@graphql-yoga/plugin-response-cache@3.23.0(graphql-yoga@5.21.0(graphql@16.9.0))(graphql@16.9.0)': dependencies: @@ -24936,9 +24988,9 @@ snapshots: '@whatwg-node/events': 0.1.2 ioredis: 5.8.2 - '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.21.0(graphql@16.12.0))': + '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.21.0(graphql@16.14.0))': dependencies: - graphql-yoga: 5.21.0(graphql@16.12.0) + graphql-yoga: 5.21.0(graphql@16.14.0) '@graphql-yoga/subscription@5.0.5': dependencies: @@ -30612,11 +30664,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@theguild/federation-composition@0.20.2(graphql@16.12.0)': + '@theguild/federation-composition@0.20.2(graphql@16.14.0)': dependencies: constant-case: 3.0.4 debug: 4.4.3(supports-color@8.1.1) - graphql: 16.12.0 + graphql: 16.14.0 json5: 2.2.3 lodash.sortby: 4.7.0 transitivePeerDependencies: @@ -34813,12 +34865,12 @@ snapshots: arrify: 1.0.1 graphql: 16.9.0 - graphql-jit@0.8.7(graphql@16.12.0): + graphql-jit@0.8.7(graphql@16.14.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) fast-json-stringify: 5.16.1 generate-function: 2.3.1 - graphql: 16.12.0 + graphql: 16.14.0 lodash.memoize: 4.1.2 lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 @@ -34905,13 +34957,6 @@ snapshots: nullthrows: 1.1.1 vscode-languageserver-types: 3.17.5 - graphql-language-service@5.5.0(graphql@16.12.0): - dependencies: - debounce-promise: 3.1.2 - graphql: 16.12.0 - nullthrows: 1.1.1 - vscode-languageserver-types: 3.17.5 - graphql-language-service@5.5.0(graphql@16.9.0): dependencies: debounce-promise: 3.1.2 @@ -34965,9 +35010,9 @@ snapshots: dependencies: graphql: 16.9.0 - graphql-ws@6.0.6(graphql@16.12.0)(ws@8.18.0): + graphql-ws@6.0.6(graphql@16.14.0)(ws@8.18.0): dependencies: - graphql: 16.12.0 + graphql: 16.14.0 optionalDependencies: ws: 8.18.0 @@ -34977,20 +35022,20 @@ snapshots: optionalDependencies: ws: 8.18.0 - graphql-yoga@5.13.3(graphql@16.12.0): + graphql-yoga@5.13.3(graphql@16.14.0): dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.25(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/schema': 10.0.25(graphql@16.14.0) + '@graphql-tools/utils': 10.9.1(graphql@16.14.0) '@graphql-yoga/logger': 2.0.1 '@graphql-yoga/subscription': 5.0.5 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server': 0.10.17 dset: 3.1.4 - graphql: 16.12.0 + graphql: 16.14.0 lru-cache: 10.2.0 tslib: 2.8.1 @@ -35011,19 +35056,19 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - graphql-yoga@5.21.0(graphql@16.12.0): + graphql-yoga@5.21.0(graphql@16.14.0): dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.25(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.14.0) + '@graphql-tools/schema': 10.0.25(graphql@16.14.0) + '@graphql-tools/utils': 10.11.0(graphql@16.14.0) '@graphql-yoga/logger': 2.0.1 '@graphql-yoga/subscription': 5.0.5 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server': 0.10.17 - graphql: 16.12.0 + graphql: 16.14.0 lru-cache: 10.2.0 tslib: 2.8.1 @@ -35058,7 +35103,7 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - graphql@16.12.0: {} + graphql@16.14.0: {} graphql@16.9.0: {} @@ -37406,14 +37451,6 @@ snapshots: monaco-editor@0.52.2: {} - monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1): - dependencies: - graphql: 16.12.0 - graphql-language-service: 5.5.0(graphql@16.12.0) - monaco-editor: 0.52.2 - picomatch-browser: 2.2.6 - prettier: 3.8.1 - monaco-graphql@1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1): dependencies: graphql: 16.9.0 @@ -37452,7 +37489,7 @@ snapshots: '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.12.0 + graphql: 16.14.0 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -37478,7 +37515,7 @@ snapshots: '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.12.0 + graphql: 16.14.0 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -39922,11 +39959,23 @@ snapshots: stylis@4.1.3: {} - subscriptions-transport-ws@0.11.0(graphql@16.12.0): + subscriptions-transport-ws@0.11.0(graphql@16.14.0): + dependencies: + backo2: 1.0.2 + eventemitter3: 3.1.2 + graphql: 16.14.0 + iterall: 1.3.0 + symbol-observable: 1.2.0 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + subscriptions-transport-ws@0.11.0(graphql@16.9.0): dependencies: backo2: 1.0.2 eventemitter3: 3.1.2 - graphql: 16.12.0 + graphql: 16.9.0 iterall: 1.3.0 symbol-observable: 1.2.0 ws: 8.18.0 From ff547c81101bbe670470032865f23d50996244fa Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 29 May 2026 08:32:49 -0700 Subject: [PATCH 19/62] Fix callback import --- packages/libraries/core/src/index.ts | 1 + packages/libraries/yoga/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/libraries/core/src/index.ts b/packages/libraries/core/src/index.ts index e19ac662971..a4fc5ff35d7 100644 --- a/packages/libraries/core/src/index.ts +++ b/packages/libraries/core/src/index.ts @@ -4,6 +4,7 @@ export type { HivePluginOptions, HiveClient, CollectUsageCallback, + CollectUsage, LegacyLogger as Logger, PersistedDocumentsCache, Layer2CacheConfiguration, diff --git a/packages/libraries/yoga/src/index.ts b/packages/libraries/yoga/src/index.ts index 7f50ab62c62..865f08e2755 100644 --- a/packages/libraries/yoga/src/index.ts +++ b/packages/libraries/yoga/src/index.ts @@ -2,13 +2,13 @@ import { DocumentNode, ExecutionArgs, GraphQLError, GraphQLSchema, Kind, parse } import { _createLRUCache, YogaServer, type GraphQLParams, type Plugin } from 'graphql-yoga'; import { autoDisposeSymbol, + CollectUsage, createHive as createHiveClient, HiveClient, HivePluginOptions, isAsyncIterable, isHiveClient, } from '@graphql-hive/core'; -import { CollectUsage } from '@graphql-hive/core/typings/client/types.js'; import { Logger } from '@graphql-hive/logger'; import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; import { version } from './version.js'; From 7fd451f5c938fcb50731e4cded4cefd52aa36ae4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 29 May 2026 15:35:42 -0700 Subject: [PATCH 20/62] Move subgraph request logic to client; handle monolith case --- .../tests/gateway-usage/plugin.spec.ts | 6 +- .../subrequests}/extract-coordinates.ts | 2 +- .../client/subrequests}/path-to-coordinate.ts | 0 packages/libraries/core/src/client/types.ts | 23 ++- packages/libraries/core/src/client/usage.ts | 146 ++++++++++++++---- .../subrequests}/path-to-coordinate.spec.ts | 2 +- packages/libraries/gateway-usage/src/index.ts | 44 +++--- .../services/usage/src/usage-processor-2.ts | 2 +- 8 files changed, 155 insertions(+), 70 deletions(-) rename packages/libraries/{gateway-usage/src => core/src/client/subrequests}/extract-coordinates.ts (98%) rename packages/libraries/{gateway-usage/src => core/src/client/subrequests}/path-to-coordinate.ts (100%) rename packages/libraries/{gateway-usage/tests => core/tests/client/subrequests}/path-to-coordinate.spec.ts (87%) diff --git a/integration-tests/tests/gateway-usage/plugin.spec.ts b/integration-tests/tests/gateway-usage/plugin.spec.ts index 2f4b745a952..303aa4bf800 100644 --- a/integration-tests/tests/gateway-usage/plugin.spec.ts +++ b/integration-tests/tests/gateway-usage/plugin.spec.ts @@ -7,7 +7,7 @@ import { getServiceHost } from 'testkit/utils'; import { describe, expect, test } from 'vitest'; import { buildSubgraphSchema } from '@apollo/subgraph'; import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; -import { createHive, useHive } from '@graphql-hive/gateway-usage'; +import { useHive } from '@graphql-hive/gateway-usage'; import { createServer } from '@hive/service-common'; async function createSubgraphService() { @@ -70,7 +70,7 @@ describe('GraphQL Hive Plugin', () => { ); const token = await createTargetAccessToken({}); const usageAddress = await getServiceHost('usage', 8081); - const client = createHive({ + const plugin = useHive({ enabled: true, token: token.secret, reporting: false, @@ -84,8 +84,8 @@ describe('GraphQL Hive Plugin', () => { graphqlEndpoint: 'http://noop/', applicationUrl: 'http://noop/', }, + fieldLevelMetricsEnabled: true, }); - const plugin = useHive(client); const subgraph = await createSubgraphService(); const gateway = createGatewayRuntime({ supergraph: ` diff --git a/packages/libraries/gateway-usage/src/extract-coordinates.ts b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts similarity index 98% rename from packages/libraries/gateway-usage/src/extract-coordinates.ts rename to packages/libraries/core/src/client/subrequests/extract-coordinates.ts index e7fbddc9db7..1debfba0d9d 100644 --- a/packages/libraries/gateway-usage/src/extract-coordinates.ts +++ b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts @@ -13,7 +13,7 @@ import { /** * Extracts true schema coordinates and counts their execution volume. */ -export function extractSchemaCoordinates( +export function extractCoordinates( schema: GraphQLSchema, document: DocumentNode, resultData: any, diff --git a/packages/libraries/gateway-usage/src/path-to-coordinate.ts b/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts similarity index 100% rename from packages/libraries/gateway-usage/src/path-to-coordinate.ts rename to packages/libraries/core/src/client/subrequests/path-to-coordinate.ts diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index 25256419c60..1433aabb2bd 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -1,4 +1,4 @@ -import type { ExecutionArgs, GraphQLErrorExtensions } from 'graphql'; +import type { DocumentNode, ExecutionArgs, GraphQLErrorExtensions, GraphQLSchema } from 'graphql'; import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue.js'; import { LogLevel as HiveLoggerLevel, Logger } from '@graphql-hive/logger'; import { MaybePromise } from '@graphql-tools/utils'; @@ -28,15 +28,18 @@ export type SubRequestCallback = (args: { type: 'ROOT' | 'ENTITY'; }) => FinishSubRequestCallback; -export type FinishSubRequestCallback = (result: { +export type FinishSubRequestCallback = (args: { /** HTTP Status Code */ status: number; - /** Number of times the field has been requested. Regardless of success or failure */ - fields: { [coordinate: string]: number }; + /** Used to calculate error code for a coordinate, with a code returned from the graphql extensions */ + result?: GraphQLResult; - /** Error code for a coordinate, with a code returned from the graphql extensions */ - errors?: { coordinate: string; code?: string }[]; + /** The GraphQL schema being accessed. Used to calculate coordinate from error path and the coordinate for field counts */ + subgraphSchema: GraphQLSchema; + + /** GraphQL operation document. Used to calculate field counts. */ + document: DocumentNode; }) => void; export type CollectUsage = { @@ -54,7 +57,7 @@ export interface HiveClient { /** Collect usage for Query and Mutation operations */ collectRequest(args: { args: ExecutionArgs; - result: GraphQLErrorsResult | AbortAction; + result: GraphQLResult | AbortAction; duration: number; /** * Persisted document if request is using a persisted document. @@ -93,7 +96,7 @@ export type AbortAction = { export type CollectUsageCallback = ( args: ExecutionArgs, - result: GraphQLErrorsResult | AbortAction, + result: GraphQLResult | AbortAction, /** * Persisted document if subscription is a persisted document. * It needs to be provided in order to collect app deployment specific information. @@ -313,6 +316,10 @@ export type HiveInternalPluginOptions = HivePluginOptions & { export type Maybe = null | undefined | T; +export type GraphQLResult> = { + data?: TData | null; +} & GraphQLErrorsResult; + export interface GraphQLErrorsResult { errors?: ReadonlyArray<{ message: string; diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index fc65c54413a..d5987a82306 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -12,12 +12,14 @@ import { version } from '../version.js'; import { createAgent } from './agent.js'; import { collectSchemaCoordinates } from './collect-schema-coordinates.js'; import { dynamicSampling, randomSampling } from './sampling.js'; +import { extractCoordinates } from './subrequests/extract-coordinates.js'; +import { pathToCoordinate } from './subrequests/path-to-coordinate.js'; import type { AbortAction, ClientInfo, CollectUsage, CollectUsageCallback, - GraphQLErrorsResult, + GraphQLResult, HiveInternalPluginOptions, HiveUsagePluginOptions, } from './types.js'; @@ -35,12 +37,12 @@ interface UsageCollector { /** collect a short lived GraphQL request (mutation/query operation) */ collectRequest(args: { args: ExecutionArgs; - result: GraphQLErrorsResult | AbortAction; + result: GraphQLResult | AbortAction; /** duration in milliseconds */ duration: number; experimental__persistedDocumentHash?: string; /** Optionally send subgraph request information. This provides a deeper level of usage metrics */ - fetches?: OperationSubgraphRequest[] | null; + fetches?: CollectedOperationSubgraphRequest[] | null; }): void; /** collect a long-lived GraphQL request/subscription (subscription operation) */ collectSubscription(args: { @@ -124,6 +126,39 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl set(action) { if (action.type === 'request') { const operation = action.data; + const fetches = operation.execution.fetches?.map((f): OperationSubgraphRequest => { + const documentRoot = f.document.definitions.find( + (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition', + )?.operation satisfies 'subscription' | 'mutation' | 'query' | undefined; + const subgraphFields = extractCoordinates( + f.subgraphSchema, + f.document, + f.result?.data, + ); + const errors: { coordinate: string; code?: string }[] = []; + for (const error of f.result?.errors ?? []) { + const coordinate = + error.path && pathToCoordinate(f.subgraphSchema, error.path, documentRoot); + if (coordinate) { + errors.push({ + coordinate, + code: error.extensions?.code as string | undefined, + }); + } + } + + return { + duration: f.duration, + start: f.start, + status: f.status, + type: f.type, + paths: f.paths, + subgraph: f.subgraph, + errors, + fields: subgraphFields, + }; + }); + reportOperations.push({ operationMapKey: operation.key, timestamp: operation.timestamp, @@ -131,7 +166,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ok: operation.execution.ok, duration: operation.execution.duration, errorsTotal: operation.execution.errorsTotal, - fetches: operation.execution.fetches, + fetches, }, metadata: { client: operation.client ?? undefined, @@ -205,9 +240,10 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl const collectRequest: UsageCollector['collectRequest'] = args => { let providedOperationName: string | undefined = undefined; try { - if (isAbortAction(args.result)) { - if (args.result.logging) { - logger.info(args.result.reason); + const result = args.result; + if (isAbortAction(result)) { + if (result.logging) { + logger.info(result.reason); } return; } @@ -233,11 +269,6 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl contextValue: args.args.contextValue, }) ) { - const errors = - args.result.errors?.map(error => ({ - code: error.extensions?.code, - path: error.path?.join('.'), - })) ?? []; const collect = collector({ schema: args.args.schema, max: options.max ?? 1000, @@ -245,6 +276,29 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl processVariables: options.processVariables ?? false, }); + let fetches = args.fetches; + if (!fetches?.length) { + /** + * No subgraph requests, so this must be a monolith. + * We still want to track the field metrics, so create an artificial + * fetch that represents the local lookup. + */ + + fetches = [ + { + document: args.args.document, + duration: args.duration, + start: 0, + status: 200, + subgraph: '', + subgraphSchema: args.args.schema, + type: 'ROOT', + paths: rootOperation.operation, + result: result, + }, + ]; + } + agent.capture( collect(document, args.args.variableValues ?? null).then(({ key, value: info }) => { return { @@ -256,9 +310,9 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl operation: info.document, fields: info.fields, execution: { - ok: errors.length === 0, + ok: !result.errors?.length, duration: args.duration, - errorsTotal: errors.length, + errorsTotal: result.errors?.length ?? 0, fetches: args.fetches, }, // TODO: operationHash is ready to accept hashes of persisted operations @@ -292,26 +346,23 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl */ collect() { const sinceStart = measureDuration(); - const fetches: OperationSubgraphRequest[] = []; - const collectSubRequest = (args: OperationSubgraphRequest) => { - fetches.push(args); - }; - + const subRequests: CollectedOperationSubgraphRequest[] = []; return { - subrequest(args) { + subrequest({ subgraph, type, paths }) { const start = sinceStart(); const sinceSubStart = measureDuration(); - return result => { + return args => { const duration = sinceSubStart(); - collectSubRequest({ + subRequests.push({ start, duration, - fields: result.fields, - status: result.status, - subgraph: args.subgraph, - type: args.type, - errors: result.errors, - paths: args.paths, + status: args.status, + subgraph, + type, + result: args.result, + document: args.document, + subgraphSchema: args.subgraphSchema, + paths, }); }; }, @@ -322,7 +373,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl result, duration, experimental__persistedDocumentHash, - fetches: fetches.length > 0 ? fetches : null, + fetches: subRequests.length > 0 ? subRequests : null, }); }, }; @@ -482,6 +533,41 @@ type OperationSubgraphRequest = { type: 'ROOT' | 'ENTITY'; }; +type CollectedOperationSubgraphRequest = { + /** Delta start time from "timestamp" */ + start: number; + + /** How long the request took */ + duration: number; + + /** HTTP Status Code */ + status: number; + + /** The graphql execution result. Used to calculate error code for a coordinate, with a code returned from the graphql extensions */ + result?: GraphQLResult; + + /** The GraphQL schema being accessed. Used to calculate coordinate from error path and the coordinate for field counts */ + subgraphSchema: GraphQLSchema; + + /** GraphQL operation document. Used to calculate field counts. */ + document: DocumentNode; + + /** Which subgraph resolved this path */ + subgraph: string; + + /** + * If this is an entity request, then this is the coordinate in the original operation that is being resolved. + * If undefined, then the path is assumed to be 'Query'. + */ + paths?: string[] | string; + + /** + * What type of request this is. Root is if resolving a root query/mutation field. Entity is + * if resolving an entity type in federation. + * */ + type: 'ROOT' | 'ENTITY'; +}; + interface CollectedOperation { key: string; timestamp: number; @@ -492,7 +578,7 @@ interface CollectedOperation { ok: boolean; duration: number; errorsTotal: number; - fetches?: OperationSubgraphRequest[] | null; + fetches?: CollectedOperationSubgraphRequest[] | null; }; persistedDocumentHash?: string; client?: ClientInfo | null; diff --git a/packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts b/packages/libraries/core/tests/client/subrequests/path-to-coordinate.spec.ts similarity index 87% rename from packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts rename to packages/libraries/core/tests/client/subrequests/path-to-coordinate.spec.ts index 66247f5427e..2cdc2f04836 100644 --- a/packages/libraries/gateway-usage/tests/path-to-coordinate.spec.ts +++ b/packages/libraries/core/tests/client/subrequests/path-to-coordinate.spec.ts @@ -1,5 +1,5 @@ import { buildSchema } from 'graphql'; -import { pathToCoordinate } from '../src/path-to-coordinate.js'; +import { pathToCoordinate } from '../../../src/client/subrequests/path-to-coordinate.js'; const schema = buildSchema(` type Query { diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts index 182b39b84fd..c9a5e60762d 100644 --- a/packages/libraries/gateway-usage/src/index.ts +++ b/packages/libraries/gateway-usage/src/index.ts @@ -8,9 +8,7 @@ import { type HivePluginOptions, } from '@graphql-hive/core'; import { GatewayPlugin } from '@graphql-hive/gateway-runtime'; -import { extractSchemaCoordinates } from './extract-coordinates.js'; import { isEntityRequest } from './is-entity-request.js'; -import { pathToCoordinate } from './path-to-coordinate.js'; import { version } from './version.js'; export function createHive(clientOrOptions: HivePluginOptions) { @@ -24,9 +22,14 @@ export function createHive(clientOrOptions: HivePluginOptions) { }); } +export type GatewayPluginOptions = HivePluginOptions & { + /** Opt in to sending subgraph metrics. This feature is */ + fieldLevelMetricsEnabled?: boolean; +}; + export function useHive(clientOrOptions: HiveClient): GatewayPlugin; -export function useHive(clientOrOptions: HivePluginOptions): GatewayPlugin; -export function useHive(clientOrOptions: HiveClient | HivePluginOptions): GatewayPlugin { +export function useHive(clientOrOptions: GatewayPluginOptions): GatewayPlugin; +export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): GatewayPlugin { const hive = isHiveClient(clientOrOptions) ? clientOrOptions : createHive({ @@ -55,8 +58,16 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Gatewa } } + const fieldLevelMetricsEnabled = isHiveClient(clientOrOptions) + ? false + : (clientOrOptions.fieldLevelMetricsEnabled ?? false); return { onSubgraphExecute({ executionRequest, subgraphName, subgraph: subgraphSchema }) { + if (!fieldLevelMetricsEnabled) { + // short circuit the entire hook to avoid processing this data. + return; + } + const collection = executionRequest.context?.__hiveUsageCollection as | ReturnType | undefined; @@ -77,30 +88,11 @@ export function useHive(clientOrOptions: HiveClient | HivePluginOptions): Gatewa return function onSubgraphExecuteDone({ result }) { if (!isAsyncIterable(result)) { - let errors: { coordinate: string; code?: string }[] | undefined = undefined; - if (result.errors) { - errors = []; - for (const err of result.errors) { - const coordinate = err.path && pathToCoordinate(subgraphSchema, err.path); - if (coordinate) { - errors.push({ - coordinate, - code: err.extensions?.code as string | undefined, - }); - } - } - } - - const fields = extractSchemaCoordinates( - subgraphSchema, - executionRequest.document, - result.data, - ); - finishSubRequest({ status: 200 /** @TODO figure out how to capture HTTP status codes */, - fields, - errors, + subgraphSchema, + result, + document: executionRequest.document, }); } }; diff --git a/packages/services/usage/src/usage-processor-2.ts b/packages/services/usage/src/usage-processor-2.ts index 2d3061eed4c..34f483869c4 100644 --- a/packages/services/usage/src/usage-processor-2.ts +++ b/packages/services/usage/src/usage-processor-2.ts @@ -278,7 +278,7 @@ const SubgraphRequestSchema = tb.Object( /** HTTP Status Code */ status: tb.Integer({ - minimum: 100, + minimum: 0, maximum: 599, }), From c152c8bfe01a604e413766835bdaae9dffe16ceb Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 29 May 2026 20:43:47 -0700 Subject: [PATCH 21/62] New usage data ingestion and processing --- .../tests/gateway-usage/plugin.spec.ts | 17 ++++++++- .../services/usage-common/src/cast-value.ts | 15 ++++++-- .../services/usage-common/src/processed.ts | 9 +++++ packages/services/usage-common/src/raw.ts | 11 +++++- .../services/usage-ingestor/src/ingestor.ts | 15 +++++++- .../services/usage-ingestor/src/metrics.ts | 10 ++++++ .../services/usage-ingestor/src/processor.ts | 15 ++++++++ .../services/usage-ingestor/src/serializer.ts | 17 +++++++++ .../services/usage-ingestor/src/writer.ts | 17 +++++++++ .../services/usage/src/usage-processor-2.ts | 35 +++++++++++++++++++ 10 files changed, 156 insertions(+), 5 deletions(-) diff --git a/integration-tests/tests/gateway-usage/plugin.spec.ts b/integration-tests/tests/gateway-usage/plugin.spec.ts index 303aa4bf800..83359be22b6 100644 --- a/integration-tests/tests/gateway-usage/plugin.spec.ts +++ b/integration-tests/tests/gateway-usage/plugin.spec.ts @@ -1,6 +1,7 @@ import { AddressInfo } from 'node:net'; import { parse } from 'graphql'; import { createLogger, createYoga } from 'graphql-yoga'; +import { readOperationsStats } from 'testkit/flow'; import { ProjectType } from 'testkit/gql/graphql'; import { initSeed } from 'testkit/seed'; import { getServiceHost } from 'testkit/utils'; @@ -65,7 +66,7 @@ describe('GraphQL Hive Plugin', () => { test('usage data includes subgraph request data', async () => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); - const { createTargetAccessToken, waitForRequestsCollected } = await createProject( + const { createTargetAccessToken, waitForRequestsCollected, target } = await createProject( ProjectType.Single, ); const token = await createTargetAccessToken({}); @@ -142,5 +143,19 @@ describe('GraphQL Hive Plugin', () => { } `); await usageCollected; + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + // await expect( + // readOperationsStats( + // { byId: target.id }, + // { + // from: yesterday.toISOString(), + // to: new Date().toISOString(), + // }, + // {}, + // token.secret, + // ), + // ).resolves }); }); diff --git a/packages/services/usage-common/src/cast-value.ts b/packages/services/usage-common/src/cast-value.ts index d8468d8f14d..9aefb78df5e 100644 --- a/packages/services/usage-common/src/cast-value.ts +++ b/packages/services/usage-common/src/cast-value.ts @@ -5,6 +5,7 @@ export function castValue(value: number): number; export function castValue(value: any[]): string; export function castValue(value?: any): string; export function castValue(value: undefined): string; +export function castValue(value: object): string; export function castValue(value?: any) { if (typeof value === 'boolean') { return castValue(value ? 1 : 0); @@ -26,6 +27,16 @@ export function castValue(value?: any) { return `"[${value.map(val => `'${val}'`).join(',')}]"`; } - return '\\N'; // NULL is \N - // Yes, it's ᴺᵁᴸᴸ not NULL :) This is what JSONStringsEachRow does for NULLs + if (value === undefined || value === null) { + return '\\N'; // NULL is \N + // Yes, it's ᴺᵁᴸᴸ not NULL :) This is what JSONStringsEachRow does for NULLs + } + + if (typeof value === 'object') { + const jsonStr = JSON.stringify(value); + return `"${jsonStr.replace(/"/g, '""')}"`; + } + + // consider throwing due to unhandled type. + return '\\N'; } diff --git a/packages/services/usage-common/src/processed.ts b/packages/services/usage-common/src/processed.ts index d0213d5eb6f..b6dad642d3b 100644 --- a/packages/services/usage-common/src/processed.ts +++ b/packages/services/usage-common/src/processed.ts @@ -39,3 +39,12 @@ export interface ProcessedAppDeploymentUsageRecord { appVersion: string; lastRequestTimestamp: number; } + +export interface ProcessedErrorRecord { + target: string; + hash: string; + timestamp: number; + expires_at: number; + /** All errors associated with this operation call. If the code isn't defined, use an empty string. */ + errors: [code: string, path: string][]; +} diff --git a/packages/services/usage-common/src/raw.ts b/packages/services/usage-common/src/raw.ts index cc216165a38..53d46015f2b 100644 --- a/packages/services/usage-common/src/raw.ts +++ b/packages/services/usage-common/src/raw.ts @@ -7,6 +7,7 @@ export interface RawReport { operations: RawOperation[]; subscriptionOperations?: RawSubscriptionOperation[]; appDeploymentUsageTimestamps?: RawAppDeploymentUsageTimestampMap; + errors?: RawErrors[]; } export interface RawAppDeploymentUsageTimestampMap { @@ -27,13 +28,21 @@ export interface RawOperation { ok: boolean; duration: number; errorsTotal: number; - errors?: { code?: string; path?: string }[]; + /** Count of how many times a coordinate was resolved by this operation */ + coordinateTotals?: { [coordinate: string]: number }; }; metadata?: { client?: ClientMetadata; }; } +export interface RawErrors { + operationMapKey: string; + timestamp: number; + expiresAt?: number; + errors: { code?: string; coordinate: string }[]; +} + export type RawSubscriptionOperation = { operationMapKey: string; timestamp: number; diff --git a/packages/services/usage-ingestor/src/ingestor.ts b/packages/services/usage-ingestor/src/ingestor.ts index 5c25c3b013a..6a6a99f372f 100644 --- a/packages/services/usage-ingestor/src/ingestor.ts +++ b/packages/services/usage-ingestor/src/ingestor.ts @@ -6,6 +6,8 @@ import { decompress } from '@hive/usage-common'; import type { KafkaEnvironment } from './environment'; import { errors, + ingestedOperationErrorsFailures, + ingestedOperationErrorsWrites, ingestedOperationRegistryFailures, ingestedOperationRegistryWrites, ingestedOperationsFailures, @@ -226,7 +228,7 @@ async function processMessage({ // Decompress and parse the message to get a list of reports const rawReports: RawReport[] = JSON.parse((await decompress(message.value!)).toString()); - const { registryRecords, operations, subscriptionOperations, appDeploymentUsageRecords } = + const { registryRecords, operations, subscriptionOperations, appDeploymentUsageRecords, errors } = await processor.processReports(rawReports); try { @@ -273,6 +275,17 @@ async function processMessage({ return Promise.reject(error); }), writer.writeAppDeploymentUsage(appDeploymentUsageRecords), + writer + .writeOperationErrors(errors) + .then(value => { + ingestedOperationErrorsWrites.inc(errors.length); + return Promise.resolve(value); + }) + .catch(error => { + ingestedOperationErrorsFailures.inc(errors.length); + // error[retryOnFailureSymbol] = true; + return Promise.reject(error); + }), ]); } catch (error) { logger.error(error); diff --git a/packages/services/usage-ingestor/src/metrics.ts b/packages/services/usage-ingestor/src/metrics.ts index 8d5031ab3cb..64165003741 100644 --- a/packages/services/usage-ingestor/src/metrics.ts +++ b/packages/services/usage-ingestor/src/metrics.ts @@ -51,6 +51,11 @@ export const ingestedOperationsWrites = new metrics.Counter({ help: 'Number of successfully ingested operations', }); +export const ingestedOperationErrorsWrites = new metrics.Counter({ + name: 'usage_ingested_operation_errors_writes', + help: 'Number of successfully ingested operations_errors', +}); + export const ingestedOperationsFailures = new metrics.Counter({ name: 'usage_ingested_operation_failures', help: 'Number of failed to ingest operations', @@ -65,3 +70,8 @@ export const ingestedOperationRegistryFailures = new metrics.Counter({ name: 'usage_ingested_operation_registry_failures', help: 'Number of failed to ingest registry records', }); + +export const ingestedOperationErrorsFailures = new metrics.Counter({ + name: 'usage_ingested_operation_errors_failures', + help: 'Number of failed to ingest operations_errors', +}); diff --git a/packages/services/usage-ingestor/src/processor.ts b/packages/services/usage-ingestor/src/processor.ts index af8fa2a94a8..86a8f854197 100644 --- a/packages/services/usage-ingestor/src/processor.ts +++ b/packages/services/usage-ingestor/src/processor.ts @@ -3,6 +3,7 @@ import { lru } from 'tiny-lru'; import { preprocessOperation } from '@graphql-hive/core'; import type { ServiceLogger } from '@hive/service-common'; import type { + ProcessedErrorRecord, ProcessedOperation, RawAppDeploymentUsageTimestampMap, RawOperation, @@ -21,6 +22,7 @@ import { } from './metrics'; import { stringifyAppDeploymentUsageRecord, + stringifyErrors, stringifyQueryOrMutationOperation, stringifyRegistryRecord, stringifySubscriptionOperation, @@ -82,6 +84,7 @@ export function createProcessor(config: { logger: ServiceLogger }) { const serializedOperations: string[] = []; const serializedSubscriptionOperations: string[] = []; const serializedRegistryRecords: string[] = []; + const serializedErrorRecords: string[] = []; const allAppDeploymentTimeStamps = new Map< string, @@ -138,6 +141,17 @@ export function createProcessor(config: { logger: ServiceLogger }) { serializedOperations.push(stringifyQueryOrMutationOperation(processedOperation)); } + for (const raw of rawReport.errors ?? []) { + const err: ProcessedErrorRecord = { + errors: raw.errors.map(e => [e.code ?? '', e.coordinate]), + expires_at: raw.expiresAt || raw.timestamp + RETENTION_FALLBACK * DAY_IN_MS, + hash: raw.operationMapKey, + target: rawReport.target, + timestamp: raw.timestamp, + }; + serializedErrorRecords.push(stringifyErrors(err)); + } + if (rawReport.subscriptionOperations) { for (const rawOperation of rawReport.subscriptionOperations) { const processedOperation = processSubscriptionOperation( @@ -242,6 +256,7 @@ export function createProcessor(config: { logger: ServiceLogger }) { subscriptionOperations: serializedSubscriptionOperations, registryRecords: serializedRegistryRecords, appDeploymentUsageRecords: serializedAppDeploymentUsageRecords, + errors: serializedErrorRecords, }; }, }; diff --git a/packages/services/usage-ingestor/src/serializer.ts b/packages/services/usage-ingestor/src/serializer.ts index 43e7ab4fab4..ed33c0e3c33 100644 --- a/packages/services/usage-ingestor/src/serializer.ts +++ b/packages/services/usage-ingestor/src/serializer.ts @@ -2,6 +2,7 @@ import { lru } from 'tiny-lru'; import { castValue, ProcessedAppDeploymentUsageRecord, + ProcessedErrorRecord, type ProcessedOperation, type ProcessedRegistryRecord, type ProcessedSubscriptionOperation, @@ -44,6 +45,7 @@ export const operationsOrder = [ 'duration', 'client_name', 'client_version', + 'coordinate_totals', ] as const; export const subscriptionOperationsOrder = [ @@ -75,6 +77,8 @@ export const appDeploymentUsageOrder = [ 'last_request', ] as const; +export const errorsOrder = ['target', 'hash', 'timestamp', 'expires_at', 'errors'] as const; + export function joinIntoSingleMessage(items: string[]): string { return items.join(delimiter); } @@ -94,6 +98,7 @@ export function stringifyQueryOrMutationOperation(operation: ProcessedOperation) duration: castValue(operation.execution.duration), client_name: castValue(operation.metadata?.client?.name), client_version: castValue(operation.metadata?.client?.version), + coordinate_totals: castValue(operation.execution.coordinateTotals), }; return Object.values(mapper).join(','); } @@ -141,6 +146,18 @@ export function stringifyAppDeploymentUsageRecord( return Object.values(mapper).join(','); } +export function stringifyErrors(record: ProcessedErrorRecord): string { + const mapper: Record, any> = { + target: castValue(record.target), + hash: castValue(record.hash), + timestamp: castDate(record.timestamp), + expires_at: castDate(record.expires_at), + errors: castValue(record.errors), + }; + + return Object.values(mapper).join(','); +} + function castDate(date: number): string { return cachedFormatDate(date).value; } diff --git a/packages/services/usage-ingestor/src/writer.ts b/packages/services/usage-ingestor/src/writer.ts index ebaabd9fbc7..b713a1f8afa 100644 --- a/packages/services/usage-ingestor/src/writer.ts +++ b/packages/services/usage-ingestor/src/writer.ts @@ -125,6 +125,23 @@ export function createWriter({ 3, ); }, + async writeOperationErrors(records: string[]) { + if (records.length === 0) { + return; + } + + const csv = joinIntoSingleMessage(records); + const compressed = await compress(csv); + + await writeCsv( + clickhouse, + agents, + `INSERT INTO operation_errors (${operationsFields}) FORMAT CSV`, + compressed, + logger, + 3, + ); + }, destroy() { httpAgent.destroy(); httpsAgent.destroy(); diff --git a/packages/services/usage/src/usage-processor-2.ts b/packages/services/usage/src/usage-processor-2.ts index 34f483869c4..ae132f36663 100644 --- a/packages/services/usage/src/usage-processor-2.ts +++ b/packages/services/usage/src/usage-processor-2.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { ServiceLogger as Logger, traceInlineSync } from '@hive/service-common'; import { + RawErrors, type ClientMetadata, type RawOperation, type RawReport, @@ -68,6 +69,7 @@ export const usageProcessorV2 = traceInlineSync( rawOperationsSize.observe(size); const rawOperations: RawOperation[] = []; + const rawErrors: RawErrors[] = []; const rawSubscriptionOperations: RawSubscriptionOperation[] = []; const lastAppDeploymentUsage = new Map<`${string}/${string}`, number>(); @@ -171,6 +173,23 @@ export const usageProcessorV2 = traceInlineSync( client = operation.metadata?.client ?? undefined; } + function sumMap(records: { [key: string]: number }[]): { [key: string]: number } | undefined { + if (records.length <= 1) { + return records[0]; + } + + const out = { + ...records[0], + }; + const [_, ...remainingRecords] = records; + for (const record of remainingRecords) { + for (const key of Object.keys(record)) { + out[key] = (out[key] ?? 0) + record[key]; + } + } + return out; + } + report.size += 1; rawOperations.push({ operationMapKey, @@ -182,11 +201,27 @@ export const usageProcessorV2 = traceInlineSync( ok: operation.execution.ok, duration: operation.execution.duration, errorsTotal: operation.execution.errorsTotal, + coordinateTotals: sumMap(operation.execution.fetches?.map(f => f.fields) ?? []), }, metadata: { client, }, }); + + const errors = operation.execution.fetches + ?.flatMap(f => f.errors) + .filter(e => e !== undefined); + if (errors?.length) { + rawErrors.push({ + operationMapKey, + timestamp: operation.timestamp, + expiresAt: targetRetentionInDays + ? operation.timestamp + targetRetentionInDays * DAY_IN_MS + : undefined, + errors, + }); + report.errors ??= rawErrors; + } } for (const operation of incomingSubscriptionOperations) { From 6004233bede1e6c0ea902b889bac7f58c2a05f58 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:32:29 -0700 Subject: [PATCH 22/62] support inserting operations with missing coordinate_totals column --- .../src/clickhouse-actions/019-usage-coordinate-counts.ts | 2 +- packages/services/usage-ingestor/src/writer.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index d9386929035..112c79a42fc 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -12,7 +12,7 @@ export const action: Action = async exec => { -- field availability is not based on operation count, which would skew the availability -- to show a higher error rate (e.g. if an array has one object that errored, then the -- availability) should be (1-N)/N, not 0% - ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) CODEC(ZSTD(1)) + ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) DEFAULT map() CODEC(ZSTD(1)) ; `); diff --git a/packages/services/usage-ingestor/src/writer.ts b/packages/services/usage-ingestor/src/writer.ts index b713a1f8afa..57a5bb585d3 100644 --- a/packages/services/usage-ingestor/src/writer.ts +++ b/packages/services/usage-ingestor/src/writer.ts @@ -68,7 +68,9 @@ export function createWriter({ await writeCsv( clickhouse, agents, - `INSERT INTO operations (${operationsFields}) FORMAT CSV`, + `INSERT INTO operations (${operationsFields}) + SETTINGS input_format_with_names_use_header = 1 + FORMAT CSV`, compressed, logger, 3, From ac7edf0898f2d866fa4d26c6294a60362f7fb398 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:46:51 -0700 Subject: [PATCH 23/62] Track fetch status --- .../tests/gateway-usage/plugin.spec.ts | 22 +++++++++---------- packages/libraries/gateway-usage/src/index.ts | 15 +++++++++++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/integration-tests/tests/gateway-usage/plugin.spec.ts b/integration-tests/tests/gateway-usage/plugin.spec.ts index 83359be22b6..35d22ad6e35 100644 --- a/integration-tests/tests/gateway-usage/plugin.spec.ts +++ b/integration-tests/tests/gateway-usage/plugin.spec.ts @@ -146,16 +146,16 @@ describe('GraphQL Hive Plugin', () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - // await expect( - // readOperationsStats( - // { byId: target.id }, - // { - // from: yesterday.toISOString(), - // to: new Date().toISOString(), - // }, - // {}, - // token.secret, - // ), - // ).resolves + const operationsStatsResult = await readOperationsStats( + { byId: target.id }, + { + from: yesterday.toISOString(), + to: new Date().toISOString(), + }, + {}, + token.secret, + ).then(r => r.expectNoGraphQLErrors()); + // @TODO after modifying the API, check the additional data (error metrics etc) + expect(operationsStatsResult.target?.operationsStats.operations.edges[0].node.count).toBe(1); }); }); diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts index c9a5e60762d..f52acf53928 100644 --- a/packages/libraries/gateway-usage/src/index.ts +++ b/packages/libraries/gateway-usage/src/index.ts @@ -41,7 +41,7 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat }); void hive.info(); - + const statusMap = new WeakMap(); if (hive[autoDisposeSymbol]) { if (global.process) { const signals = Array.isArray(hive[autoDisposeSymbol]) @@ -62,6 +62,17 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat ? false : (clientOrOptions.fieldLevelMetricsEnabled ?? false); return { + onFetch({ executionRequest }) { + /** Only if the execution request is set, then this is a subgraph execution. */ + if (!executionRequest) { + return; + } + + return function onFetchDone({ response }) { + statusMap.set(executionRequest, response.status); + }; + }, + onSubgraphExecute({ executionRequest, subgraphName, subgraph: subgraphSchema }) { if (!fieldLevelMetricsEnabled) { // short circuit the entire hook to avoid processing this data. @@ -89,7 +100,7 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat return function onSubgraphExecuteDone({ result }) { if (!isAsyncIterable(result)) { finishSubRequest({ - status: 200 /** @TODO figure out how to capture HTTP status codes */, + status: statusMap.get(executionRequest) ?? 200, subgraphSchema, result, document: executionRequest.document, From 6dec2f171b4324faa7ecd345f03818ba7f30637e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:37:16 -0700 Subject: [PATCH 24/62] Remove the setting because it's enabled by default --- packages/services/usage-ingestor/src/writer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/services/usage-ingestor/src/writer.ts b/packages/services/usage-ingestor/src/writer.ts index 57a5bb585d3..696d7a44629 100644 --- a/packages/services/usage-ingestor/src/writer.ts +++ b/packages/services/usage-ingestor/src/writer.ts @@ -65,11 +65,13 @@ export function createWriter({ const csv = joinIntoSingleMessage(operations); const compressed = await compress(csv); + // Note that `SETTINGS input_format_with_names_use_header = 1` is enabled by default. + // If migrating this table in the future, be sure to double check this via + // SELECT name, value, changed, description FROM system.settings WHERE name = 'input_format_with_names_use_header'; await writeCsv( clickhouse, agents, `INSERT INTO operations (${operationsFields}) - SETTINGS input_format_with_names_use_header = 1 FORMAT CSV`, compressed, logger, From 8db01779cbe839141da4e5f3b01d799b048f77a0 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:59:55 -0700 Subject: [PATCH 25/62] Remove noisy debug logs from clickhouse; get errors and field coordinate counts populating --- docker/configs/clickhouse/logging.xml | 1 + .../client/subrequests/path-to-coordinate.ts | 5 +- packages/libraries/core/src/client/usage.ts | 6 +- .../018-usage-coordinate-errors.ts | 3 +- .../019-usage-coordinate-counts.ts | 15 +++-- .../providers/operations-manager.ts | 5 ++ .../operations/providers/operations-reader.ts | 12 +++- packages/services/api/src/shared/entities.ts | 1 + packages/services/storage/src/index.ts | 2 + .../services/usage-common/src/cast-value.ts | 26 +++++++- .../services/usage-common/src/processed.ts | 2 +- packages/services/usage-common/src/raw.ts | 4 +- .../__tests__/serializer.spec.ts | 4 ++ .../services/usage-ingestor/src/processor.ts | 8 +-- .../services/usage-ingestor/src/serializer.ts | 21 +++++-- .../services/usage-ingestor/src/writer.ts | 5 +- .../services/usage/src/usage-processor-2.ts | 48 +++++++------- scripts/seed-usage.ts | 63 ++++++++++++++++--- 18 files changed, 170 insertions(+), 61 deletions(-) diff --git a/docker/configs/clickhouse/logging.xml b/docker/configs/clickhouse/logging.xml index 3c8edfba718..6aa28cbf5e9 100644 --- a/docker/configs/clickhouse/logging.xml +++ b/docker/configs/clickhouse/logging.xml @@ -1,5 +1,6 @@ true + information \ No newline at end of file diff --git a/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts b/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts index 8f998b9a463..7f3ce16325d 100644 --- a/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts +++ b/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts @@ -32,9 +32,10 @@ export function pathToCoordinate( const field = fields[segment]; if (!field) { - throw new Error( - `Field '${segment}' not found on type '${currentType.name}'. Was this aliased?`, + console.warn( + `Hive Usage Client: Field '${segment}' not found on type '${currentType.name}'. Was this aliased? Error ignored.`, ); + return; } // Update the coordinate to the current Type.field diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index d5987a82306..4dfad4c166f 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -152,7 +152,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl start: f.start, status: f.status, type: f.type, - paths: f.paths, + paths: f.paths ?? 'Query', subgraph: f.subgraph, errors, fields: subgraphFields, @@ -294,7 +294,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl subgraphSchema: args.args.schema, type: 'ROOT', paths: rootOperation.operation, - result: result, + result, }, ]; } @@ -524,7 +524,7 @@ type OperationSubgraphRequest = { * If this is an entity request, then this is the coordinate in the original operation that is being resolved. * If undefined, then the path is assumed to be 'Query'. */ - paths?: string[] | string; + paths: string[] | string; /** * What type of request this is. Root is if resolving a root query/mutation field. Entity is diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 859d9c9eade..837a27c1adf 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -21,7 +21,8 @@ export const action: Action = async exec => { -- hash stores a md5 of the body, coordinate, and operation name -- stores as raw binary which requires hex() on returned md5 and unhex() on input -- this saves space by allowing a FixedString(16) compared to FixedString(32) - , hash FixedString(16) CODEC(ZSTD(1)) + , hash FixedString(16) DEFAULT unhex(hash_raw) CODEC(ZSTD(1)) + , hash_raw String EPHEMERAL -- Captured from CSV but not stored , timestamp DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)) diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index 112c79a42fc..6dabf2999ae 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -5,14 +5,17 @@ export const action: Action = async exec => { * Add a column to hold the real executed field counts. This differs from existing counts, which * are for the number of times the operation was executed and (in the operation_collection table) * whether or not the coordinate is included in an operation's body. + * + * "coordinate_totals" Counts the coordinates actually called during execution. This is necessary so that + * field availability is not based on operation count, which would skew the availability + * to show a higher error rate (e.g. if an array has one object that errored, then the + * availability) should be (1-N)/N, not 0% */ await exec(` ALTER TABLE default.operations - -- Counts the coordinates actually called during execution. This is necessary so that - -- field availability is not based on operation count, which would skew the availability - -- to show a higher error rate (e.g. if an array has one object that errored, then the - -- availability) should be (1-N)/N, not 0% - ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) DEFAULT map() CODEC(ZSTD(1)) + ADD COLUMN IF NOT EXISTS coordinate_totals Map(String, UInt32) + DEFAULT map() + CODEC(ZSTD(1)) ; `); @@ -88,7 +91,7 @@ export const action: Action = async exec => { AS SELECT target - , hash + , unhex(hash) AS hash , toStartOfMinute(timestamp) AS timestamp , toStartOfMinute(expires_at) AS expires_at , coord_total.1 as coordinate diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 830ff976a37..36ad76b184f 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -1159,10 +1159,15 @@ export class OperationsManager { }, }); + const org = await this.storage.getOrganization({ + organizationId: organization, + }); + const rows = await this.reader.countCoordinatesOfType({ target, period, typename, + aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', }); const records: { diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 0c3f163dd0c..ad05b37f561 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -89,7 +89,9 @@ export class OperationsReader { } | number; query( - aggregationTableName: (tableName: 'operations' | 'clients' | 'coordinates') => RawValue, + aggregationTableName: ( + tableName: 'operations' | 'clients' | 'coordinates' | 'coordinate_counts', + ) => RawValue, ): SqlValue; queryId(aggregation: 'daily' | 'hourly' | 'minutely'): `${string}_${typeof aggregation}`; }) { @@ -2119,6 +2121,7 @@ export class OperationsReader { target: string; period: DateRange; typename: string; + aggregateSource?: 'coordinate_counts' | 'coordinates'; }>, ) => { const aggregationMap = new Map< @@ -2127,6 +2130,7 @@ export class OperationsReader { target: string; period: DateRange; typenames: string[]; + aggregateSource?: 'coordinate_counts' | 'coordinates'; } >(); @@ -2147,6 +2151,7 @@ export class OperationsReader { target: selector.target, period: selector.period, typenames: [selector.typename], + aggregateSource: selector.aggregateSource, }); } else { value.typenames.push(selector.typename); @@ -2173,6 +2178,7 @@ export class OperationsReader { target: selector.target, period: selector.period, typenames: selector.typenames, + aggregateSource: selector.aggregateSource, }), ); } @@ -2196,10 +2202,12 @@ export class OperationsReader { target, period, typenames, + aggregateSource = 'coordinates', }: { target: string; period: DateRange; typenames: string[]; + aggregateSource?: 'coordinate_counts' | 'coordinates'; }) { const typesConditions = typenames.map( t => sql`coordinate = ${t} OR coordinate LIKE ${t + '.%'}`, @@ -2210,7 +2218,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT coordinate, sum(total) as total FROM ${aggregationTableName('coordinates')} + SELECT coordinate, sum(total) as total FROM ${aggregationTableName(aggregateSource)} ${this.createFilter({ target, period, diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index cc9bf55cc65..c6a018558d9 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -156,6 +156,7 @@ export interface Organization { appDeployments: boolean; otelTracing: boolean; schemaProposals: boolean; + subgraphVisibility: boolean; }; zendeskId: string | null; /** ID of the user that owns the organization */ diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 7073d283c2b..eb6fe22deb2 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4079,6 +4079,7 @@ const FeatureFlagsModel = z /** whether otel tracing is enabled for the given organization */ otelTracing: z.boolean().default(false), schemaProposals: z.boolean().default(false), + subgraphVisibility: z.boolean().default(false), }) .optional() .nullable() @@ -4090,6 +4091,7 @@ const FeatureFlagsModel = z appDeployments: false, otelTracing: false, schemaProposals: false, + subgraphVisibility: false, }, ); diff --git a/packages/services/usage-common/src/cast-value.ts b/packages/services/usage-common/src/cast-value.ts index 9aefb78df5e..3f0db7eac99 100644 --- a/packages/services/usage-common/src/cast-value.ts +++ b/packages/services/usage-common/src/cast-value.ts @@ -24,6 +24,7 @@ export function castValue(value?: any) { } if (Array.isArray(value)) { + // This does not handle an array of anything but strings return `"[${value.map(val => `'${val}'`).join(',')}]"`; } @@ -34,9 +35,32 @@ export function castValue(value?: any) { if (typeof value === 'object') { const jsonStr = JSON.stringify(value); - return `"${jsonStr.replace(/"/g, '""')}"`; + return `"${jsonStr.replace(/'/g, "''").replace(/"/g, "'")}"`; } // consider throwing due to unhandled type. return '\\N'; } + +export function castTuple(value: any[]): string { + const contents = value.map(formatClickHouseValue).join(','); + return `(${contents})`; +} + +const formatClickHouseValue = (val: unknown): string => { + if (typeof val === 'string') { + const escaped = val.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return `'${escaped}'`; + } + if (val === null || val === undefined) { + return '\\N'; + } + if (val instanceof Date) { + return `'${val.toISOString().slice(0, 19).replace('T', ' ')}'`; + } + // If your tuples contain nested arrays, recurse through them + if (Array.isArray(val)) { + return `[${val.map(formatClickHouseValue).join(',')}]`; + } + return String(val); +}; diff --git a/packages/services/usage-common/src/processed.ts b/packages/services/usage-common/src/processed.ts index b6dad642d3b..8ce16c14fb5 100644 --- a/packages/services/usage-common/src/processed.ts +++ b/packages/services/usage-common/src/processed.ts @@ -40,7 +40,7 @@ export interface ProcessedAppDeploymentUsageRecord { lastRequestTimestamp: number; } -export interface ProcessedErrorRecord { +export interface ProcessedOperationErrorRecord { target: string; hash: string; timestamp: number; diff --git a/packages/services/usage-common/src/raw.ts b/packages/services/usage-common/src/raw.ts index 53d46015f2b..f7e84101b14 100644 --- a/packages/services/usage-common/src/raw.ts +++ b/packages/services/usage-common/src/raw.ts @@ -7,7 +7,7 @@ export interface RawReport { operations: RawOperation[]; subscriptionOperations?: RawSubscriptionOperation[]; appDeploymentUsageTimestamps?: RawAppDeploymentUsageTimestampMap; - errors?: RawErrors[]; + errors?: RawOperationErrors[]; } export interface RawAppDeploymentUsageTimestampMap { @@ -36,7 +36,7 @@ export interface RawOperation { }; } -export interface RawErrors { +export interface RawOperationErrors { operationMapKey: string; timestamp: number; expiresAt?: number; diff --git a/packages/services/usage-ingestor/__tests__/serializer.spec.ts b/packages/services/usage-ingestor/__tests__/serializer.spec.ts index a369eeab2f4..18027a7eae8 100644 --- a/packages/services/usage-ingestor/__tests__/serializer.spec.ts +++ b/packages/services/usage-ingestor/__tests__/serializer.spec.ts @@ -29,6 +29,7 @@ test('stringify operation in correct format and order', () => { ok: true, errorsTotal: 0, duration: 230, + coordinateTotals: { foo: 3 }, }, document: `{ foo }`, operationType: 'query' as any, @@ -50,6 +51,7 @@ test('stringify operation in correct format and order', () => { ok: false, errorsTotal: 1, duration: 250, + coordinateTotals: { foo: 1 }, }, document: `{ foo }`, operationType: 'query' as any, @@ -70,6 +72,7 @@ test('stringify operation in correct format and order', () => { /* duration */ 230, /* client_name */ `"clientName"`, /* client_version */ `"clientVersion"`, + /* coordinates_total */ `"{""foo"":3}"`, ].join(','), [ /* organization */ `"my-organization"`, @@ -82,6 +85,7 @@ test('stringify operation in correct format and order', () => { /* duration */ 250, /* client_name */ `\\N`, /* client_version */ `\\N`, + /* coordinates_total */ `"{""foo"":1}"`, ].join(','), ].join('\n'), ); diff --git a/packages/services/usage-ingestor/src/processor.ts b/packages/services/usage-ingestor/src/processor.ts index 86a8f854197..2be6c6774a6 100644 --- a/packages/services/usage-ingestor/src/processor.ts +++ b/packages/services/usage-ingestor/src/processor.ts @@ -3,8 +3,8 @@ import { lru } from 'tiny-lru'; import { preprocessOperation } from '@graphql-hive/core'; import type { ServiceLogger } from '@hive/service-common'; import type { - ProcessedErrorRecord, ProcessedOperation, + ProcessedOperationErrorRecord, RawAppDeploymentUsageTimestampMap, RawOperation, RawOperationMap, @@ -22,7 +22,7 @@ import { } from './metrics'; import { stringifyAppDeploymentUsageRecord, - stringifyErrors, + stringifyOperationErrors, stringifyQueryOrMutationOperation, stringifyRegistryRecord, stringifySubscriptionOperation, @@ -142,14 +142,14 @@ export function createProcessor(config: { logger: ServiceLogger }) { } for (const raw of rawReport.errors ?? []) { - const err: ProcessedErrorRecord = { + const err: ProcessedOperationErrorRecord = { errors: raw.errors.map(e => [e.code ?? '', e.coordinate]), expires_at: raw.expiresAt || raw.timestamp + RETENTION_FALLBACK * DAY_IN_MS, hash: raw.operationMapKey, target: rawReport.target, timestamp: raw.timestamp, }; - serializedErrorRecords.push(stringifyErrors(err)); + serializedErrorRecords.push(stringifyOperationErrors(err)); } if (rawReport.subscriptionOperations) { diff --git a/packages/services/usage-ingestor/src/serializer.ts b/packages/services/usage-ingestor/src/serializer.ts index ed33c0e3c33..dab5e3f04ce 100644 --- a/packages/services/usage-ingestor/src/serializer.ts +++ b/packages/services/usage-ingestor/src/serializer.ts @@ -1,8 +1,9 @@ import { lru } from 'tiny-lru'; import { + castTuple, castValue, ProcessedAppDeploymentUsageRecord, - ProcessedErrorRecord, + ProcessedOperationErrorRecord, type ProcessedOperation, type ProcessedRegistryRecord, type ProcessedSubscriptionOperation, @@ -77,7 +78,13 @@ export const appDeploymentUsageOrder = [ 'last_request', ] as const; -export const errorsOrder = ['target', 'hash', 'timestamp', 'expires_at', 'errors'] as const; +export const operationErrorsOrder = [ + 'target', + 'hash_raw', + 'timestamp', + 'expires_at', + 'errors', +] as const; export function joinIntoSingleMessage(items: string[]): string { return items.join(delimiter); @@ -146,13 +153,15 @@ export function stringifyAppDeploymentUsageRecord( return Object.values(mapper).join(','); } -export function stringifyErrors(record: ProcessedErrorRecord): string { - const mapper: Record, any> = { +export function stringifyOperationErrors(record: ProcessedOperationErrorRecord): string { + const mapper: Record, any> = { target: castValue(record.target), - hash: castValue(record.hash), + hash_raw: castValue(record.hash), timestamp: castDate(record.timestamp), expires_at: castDate(record.expires_at), - errors: castValue(record.errors), + errors: record.errors + ? `"[${record.errors?.map(castTuple).join(',')}]"` + : castValue(record.errors), }; return Object.values(mapper).join(','); diff --git a/packages/services/usage-ingestor/src/writer.ts b/packages/services/usage-ingestor/src/writer.ts index 696d7a44629..ddf319df4fd 100644 --- a/packages/services/usage-ingestor/src/writer.ts +++ b/packages/services/usage-ingestor/src/writer.ts @@ -7,6 +7,7 @@ import { writeDuration } from './metrics'; import { appDeploymentUsageOrder, joinIntoSingleMessage, + operationErrorsOrder, operationsOrder, registryOrder, subscriptionOperationsOrder, @@ -26,6 +27,7 @@ const operationsFields = operationsOrder.join(', '); const subscriptionOperationsFields = subscriptionOperationsOrder.join(', '); const registryFields = registryOrder.join(', '); const appDeploymentUsageFields = appDeploymentUsageOrder.join(', '); +const operationErrorsFields = operationErrorsOrder.join(', '); const agentConfig: Agent.HttpOptions = { // Keep sockets around in a pool to be used by other requests in the future @@ -136,11 +138,12 @@ export function createWriter({ const csv = joinIntoSingleMessage(records); const compressed = await compress(csv); + // create input structure schema await writeCsv( clickhouse, agents, - `INSERT INTO operation_errors (${operationsFields}) FORMAT CSV`, + `INSERT INTO operation_errors (${operationErrorsFields}) FORMAT CSV`, compressed, logger, 3, diff --git a/packages/services/usage/src/usage-processor-2.ts b/packages/services/usage/src/usage-processor-2.ts index ae132f36663..b262cf407f4 100644 --- a/packages/services/usage/src/usage-processor-2.ts +++ b/packages/services/usage/src/usage-processor-2.ts @@ -1,11 +1,11 @@ import { createHash, randomUUID } from 'node:crypto'; import { ServiceLogger as Logger, traceInlineSync } from '@hive/service-common'; -import { - RawErrors, - type ClientMetadata, - type RawOperation, - type RawReport, - type RawSubscriptionOperation, +import type { + ClientMetadata, + RawOperation, + RawOperationErrors, + RawReport, + RawSubscriptionOperation, } from '@hive/usage-common'; import * as tb from '@sinclair/typebox'; import * as tc from '@sinclair/typebox/compiler'; @@ -69,7 +69,7 @@ export const usageProcessorV2 = traceInlineSync( rawOperationsSize.observe(size); const rawOperations: RawOperation[] = []; - const rawErrors: RawErrors[] = []; + const rawErrors: RawOperationErrors[] = []; const rawSubscriptionOperations: RawSubscriptionOperation[] = []; const lastAppDeploymentUsage = new Map<`${string}/${string}`, number>(); @@ -173,23 +173,6 @@ export const usageProcessorV2 = traceInlineSync( client = operation.metadata?.client ?? undefined; } - function sumMap(records: { [key: string]: number }[]): { [key: string]: number } | undefined { - if (records.length <= 1) { - return records[0]; - } - - const out = { - ...records[0], - }; - const [_, ...remainingRecords] = records; - for (const record of remainingRecords) { - for (const key of Object.keys(record)) { - out[key] = (out[key] ?? 0) + record[key]; - } - } - return out; - } - report.size += 1; rawOperations.push({ operationMapKey, @@ -495,4 +478,21 @@ function getTypeBoxErrors(errors: tc.ValueErrorIterator): Array { }); } +function sumMap(records: { [key: string]: number }[]): { [key: string]: number } | undefined { + if (records.length <= 1) { + return records[0]; + } + + const out = { + ...records[0], + }; + const [_, ...remainingRecords] = records; + for (const record of remainingRecords) { + for (const key of Object.keys(record)) { + out[key] = (out[key] ?? 0) + record[key]; + } + } + return out; +} + const DAY_IN_MS = 86_400_000; diff --git a/scripts/seed-usage.ts b/scripts/seed-usage.ts index e3d59ba6ab5..bb82597cce6 100644 --- a/scripts/seed-usage.ts +++ b/scripts/seed-usage.ts @@ -3,10 +3,11 @@ * `TARGET= FEDERATION=1 STAGE=local TOKEN= pnpm seed:usage` */ import { parse as parsePath } from 'path'; -import { buildSchema, DocumentNode, GraphQLSchema, parse, print } from 'graphql'; +import { buildSchema, DocumentNode, graphql, GraphQLSchema, parse, print } from 'graphql'; import { createHive, HiveClient } from '@graphql-hive/core'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { loadDocuments, loadSchema, loadTypedefs } from '@graphql-tools/load'; +import { addMocksToSchema } from '@graphql-tools/mock'; import { composeServices, ServiceDefinition, @@ -102,19 +103,50 @@ function start(args: { instance: HiveClient; schema: GraphQLSchema; queries: Doc const randNumber = Math.random() * 100; const done = args.instance.collectUsage(); - await done( + const document = chooseQuery(args.queries); + const result = await graphql({ schema: args.schema, source: print(document) }); + const rootFields = getDocumentRoot(document); + if (randNumber > 95) { + result.errors = [ + { + message: 'oops', + path: rootFields ? [rootFields[0]] : undefined, + extensions: { code: 'BAD_THING' }, + } as any, + ]; + } + + // to truly test, we'd need an entire supergraph gateway with mocked subgraphs. + const subrequestDone = done.subrequest({ subgraph: 'users', type: 'ROOT' }); + + subrequestDone({ + document, + status: 200, + subgraphSchema: args.schema, + result, + }); + + await done.finish( { - document: chooseQuery(args.queries), + document, schema: args.schema, variableValues: {}, contextValue: {}, }, randNumber <= 95 - ? { - errors: undefined, - } + ? result : { - errors: [{ message: 'oops' }], + /** @NOTE this is used to determine error coordinates for the operation. These are used for + * conditional breaking changes, but not for field level errors because we want to attribute + * field errors to the corresponding subgraph. + */ + errors: [ + { + message: 'oops', + path: rootFields ? [rootFields[0]] : null, + extensions: { code: 'BAD_THING' }, + }, + ], }, ); } @@ -171,5 +203,20 @@ if (isFederation === false) { } return document; }); - start({ instance, schema: buildSchema(apiSchema), queries }); + + const schema = buildSchema(apiSchema); + const schemaWithMocks = addMocksToSchema({ schema }); + start({ instance, schema: schemaWithMocks, queries }); +} + +function getDocumentRoot(documentNode: DocumentNode): string[] | null { + const operationDefinition = documentNode.definitions.find( + def => def.kind === 'OperationDefinition', + ); + if (!operationDefinition?.selectionSet) { + return null; + } + return operationDefinition.selectionSet.selections + .filter(selection => selection.kind === 'Field') + .map(selection => selection.name.value); } From 4307b116425cfe2ac57a0a6dec682c753b6870b7 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:40:14 -0700 Subject: [PATCH 26/62] Add explorer and coordinate insights error metrics --- .../src/modules/operations/module.graphql.ts | 7 + .../providers/operations-manager.ts | 85 ++++- .../operations/providers/operations-reader.ts | 300 +++++++++++++++++- .../resolvers/SchemaCoordinateStats.ts | 30 +- .../modules/schema/module.graphql.mappers.ts | 3 + .../api/src/modules/schema/module.graphql.ts | 7 + .../src/modules/schema/resolvers/Target.ts | 7 + .../services/api/src/modules/schema/utils.ts | 4 + packages/services/storage/src/db/types.ts | 11 + .../src/components/target/explorer/common.tsx | 5 + .../src/pages/target-insights-coordinate.tsx | 48 ++- 11 files changed, 496 insertions(+), 11 deletions(-) diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index a2cae1a7486..87ec13e6b96 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -159,7 +159,14 @@ export default gql` type SchemaCoordinateStats { requestsOverTime(resolution: Int!): [RequestsOverTime!]! + + """ + How many times this coordinate has reported an error. This is available only if subgraph + visibility is enabled for your organization and the gateway is sending field level metrics. + """ + failuresOverTime(resolution: Int!): [FailuresOverTime!] totalRequests: SafeInt! @tag(name: "public") + totalFailures: SafeInt operations: OperationStatsValuesConnection! @tag(name: "public") clients: ClientStatsValuesConnection! @tag(name: "public") } diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 36ad76b184f..22339c27f75 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -246,6 +246,34 @@ export class OperationsManager { .then(r => r.total); } + async countFailuresWithSchemaCoordinate({ + organizationId, + projectId, + targetId, + period, + schemaCoordinate, + }: { + period: DateRange; + schemaCoordinate: string; + } & Listify) { + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + + return this.reader + .countCoordinateFailure({ + targetIds: Array.isArray(targetId) ? targetId : [targetId], + period, + schemaCoordinate, + }) + .then(value => value[schemaCoordinate]); + } + async countRequestsAndFailures({ organizationId: organization, projectId: project, @@ -743,6 +771,33 @@ export class OperationsManager { }); } + async readCoordinateFailuresOverTime({ + targetId, + organizationId, + period, + resolution, + schemaCoordinate, + }: { + period: DateRange; + resolution: number; + schemaCoordinate: string; + } & TargetSelector) { + const org = await this.storage.getOrganization({ + organizationId, + }); + + if (org.featureFlags.subgraphVisibility !== true) { + return []; + } + + return this.reader.getCoordinateFailuresOverTime({ + period, + resolution, + schemaCoordinate, + targetId, + }); + } + async readGeneralDurationPercentiles({ period, organizationId: organization, @@ -1163,17 +1218,27 @@ export class OperationsManager { organizationId: organization, }); - const rows = await this.reader.countCoordinatesOfType({ - target, - period, - typename, - aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', - }); + const [rows, errorRows] = await Promise.all([ + this.reader.countCoordinatesOfType({ + target, + period, + typename, + aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', + }), + org.featureFlags.subgraphVisibility + ? this.reader.countErrorCoordinatesOfType({ + target, + period, + typename, + }) + : Promise.resolve(), + ]); const records: { [coordinate: string]: { total: number; isUsed: boolean; + errorTotal?: number; }; } = {}; @@ -1184,6 +1249,14 @@ export class OperationsManager { }; } + if (org.featureFlags.subgraphVisibility && errorRows) { + for (const row of errorRows) { + if (records[row.coordinate]) { + records[row.coordinate].errorTotal ??= row.total; + } + } + } + return records; } diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index ad05b37f561..b4f303d6ce4 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -90,7 +90,12 @@ export class OperationsReader { | number; query( aggregationTableName: ( - tableName: 'operations' | 'clients' | 'coordinates' | 'coordinate_counts', + tableName: + | 'operations' + | 'clients' + | 'coordinates' + | 'coordinate_counts' + | 'coordinate_errors', ) => RawValue, ): SqlValue; queryId(aggregation: 'daily' | 'hourly' | 'minutely'): `${string}_${typeof aggregation}`; @@ -296,6 +301,108 @@ export class OperationsReader { return stats; } + countCoordinateFailure = batchBy< + { + schemaCoordinate: string; + targetIds: readonly string[]; + period: DateRange; + operations?: readonly string[]; + excludedClients?: readonly string[] | null; + }, + Record + >( + item => + `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}`, + async items => { + const schemaCoordinates = items.map(item => item.schemaCoordinate); + return await this.countCoordinateFailures({ + targetIds: items[0].targetIds, + excludedClients: items[0].excludedClients, + period: items[0].period, + operations: items[0].operations, + schemaCoordinates, + }).then(result => + items.map(item => + Promise.resolve({ [item.schemaCoordinate]: result[item.schemaCoordinate] }), + ), + ); + }, + ); + + public async countCoordinateFailures({ + schemaCoordinates, + targetIds, + period, + operations, + excludedClients, + }: { + schemaCoordinates: readonly string[]; + targetIds: string | readonly string[]; + period: DateRange; + operations?: readonly string[]; + excludedClients?: readonly string[] | null; + }) { + const conditions = [sql`(coordinate IN (${sql.array(schemaCoordinates, 'String')}))`]; + + if (Array.isArray(excludedClients) && excludedClients.length > 0) { + // Eliminate coordinates fetched by excluded clients. + // We can connect a coordinate to a client by using the hash column. + // The hash column is basically a unique identifier of a GraphQL operation. + // In the following query we fetch all hashes that were used only by the excluded clients. + conditions.push(sql` + hash NOT IN ( + SELECT hash FROM ( + SELECT + hash, + countIf(client_name NOT IN (${sql.array( + excludedClients, + 'String', + )})) as non_excluded_clients_total + FROM clients_daily ${this.createFilter({ + target: targetIds, + period, + })} + GROUP BY hash + ) WHERE non_excluded_clients_total = 0 + ) + `); + } + + const res = await this.clickHouse.query<{ + total: string; + coordinate: string; + }>({ + query: sql` + SELECT + coordinate, + sum(total_errors) as total + FROM coordinate_errors_daily + ${this.createFilter({ + target: targetIds, + period, + operations, + extra: conditions, + })} + GROUP BY coordinate + `, + queryId: 'count_failure_fields_v1', + timeout: 30_000, + }); + + const stats: Record = {}; + for (const row of res.data) { + stats[row.coordinate] = ensureNumber(row.total); + } + + for (const coordinate of schemaCoordinates) { + if (typeof stats[coordinate] !== 'number') { + stats[coordinate] = 0; + } + } + + return stats; + } + private async countFields({ fields, target, @@ -2005,6 +2112,76 @@ export class OperationsReader { return result.data.map(row => row.client_name); } + async getCoordinateFailuresOverTime({ + targetId, + period, + resolution, + schemaCoordinate, + }: { + targetId: string; + period: DateRange; + resolution: number; + schemaCoordinate: string; + }) { + const interval = calculateTimeWindow({ period, resolution }); + const intervalRaw = this.clickHouse.translateWindow(interval); + const roundedPeriod = { + from: toStartOfInterval(period.from, interval.value, interval.unit), + to: toEndOfInterval(period.to, interval.value, interval.unit), + }; + const startDateTimeFormatted = formatDate(roundedPeriod.from); + const endDateTimeFormatted = formatDate(roundedPeriod.to); + + const query = this.pickAggregationByPeriod({ + timeout: 15_000, + period, + resolution, + queryId: aggregation => `coord_failures_over_time_${aggregation}`, + query: aggregationTableName => { + return sql` + SELECT + date, + total, + FROM ( + SELECT + toDateTime( + intDiv( + toUnixTimestamp(timestamp), + toUInt32(${String(interval.seconds)}) + ) * toUInt32(${String(interval.seconds)}) + ) as date, + sum(total_errors) as total + FROM ${aggregationTableName('coordinate_errors')} + ${this.createFilter({ + target: targetId, + period: roundedPeriod, + extra: [sql`coordinate = ${schemaCoordinate}`], + })} + GROUP BY date + ORDER BY date + WITH FILL + FROM toDateTime(${startDateTimeFormatted}, 'UTC') + TO toDateTime(${endDateTimeFormatted}, 'UTC') + STEP INTERVAL ${intervalRaw} + ) + `; + }, + }); + + // multiply by 1000 to convert to milliseconds + const result = await this.clickHouse.query<{ + date: string; + total: number; + }>(query); + + return result.data.map(row => { + return { + date: toUnixTimestamp(row.date), + value: ensureNumber(row.total), + }; + }); + } + private async getDurationAndCountOverTime({ target, period, @@ -2237,6 +2414,127 @@ export class OperationsReader { })); } + // Identical to count coordinates, but for field errors + /** + * Counts the number of errors for a specific coordinate. This is the total resolved count. + * Note that in a single request, one coordinate can have multiple errors. + */ + countErrorCoordinatesOfType = batch( + async ( + selectors: Array<{ + target: string; + period: DateRange; + typename: string; + }>, + ) => { + const aggregationMap = new Map< + string, + { + target: string; + period: DateRange; + typenames: string[]; + } + >(); + + const makeKey = (selector: { target: string; period: DateRange }) => + `${ + selector.target + }-${selector.period.from.toISOString()}-${selector.period.to.toISOString()}`; + + // Groups the type names by their target and period + // The idea here is to make the least possible number of queries to ClickHouse + // by fetching all selected type names of the same target and period. + for (const selector of selectors) { + const key = makeKey(selector); + const value = aggregationMap.get(key); + + if (!value) { + aggregationMap.set(key, { + target: selector.target, + period: selector.period, + typenames: [selector.typename], + }); + } else { + value.typenames.push(selector.typename); + } + } + + const resultMap = new Map< + string, + Promise< + { + coordinate: string; + total: number; + }[] + > + >(); + + // Do the actual call to ClickHouse to get the coordinates and counts of selected type names. + for (const selector of aggregationMap.values()) { + const key = makeKey(selector); + + resultMap.set( + key, + this.countErrorCoordinatesOfTypes({ + target: selector.target, + period: selector.period, + typenames: selector.typenames, + }), + ); + } + + // Because the `batch` function is used (it's a similar concept to DataLoader), + // it has tu return a map of promises matching provided selectors in exact same order. + return selectors.map(selector => { + const key = makeKey(selector); + const value = resultMap.get(key); + + if (!value) { + throw new Error(`Could not find data for ${key} selector`); + } + + return value; + }); + }, + ); + + private async countErrorCoordinatesOfTypes({ + target, + period, + typenames, + }: { + target: string; + period: DateRange; + typenames: string[]; + }) { + const typesConditions = typenames.map( + t => sql`coordinate = ${t} OR coordinate LIKE ${t + '.%'}`, + ); + const result = await this.clickHouse.query<{ + coordinate: string; + total: number; + }>( + this.pickAggregationByPeriod({ + query: aggregationTableName => sql` + SELECT coordinate, sum(total_errors) as total FROM ${aggregationTableName('coordinate_errors')} + ${this.createFilter({ + target, + period, + extra: [sql`(${sql.join(typesConditions, ' OR ')})`], + })} + GROUP BY coordinate`, + queryId: aggregation => `coordinates_per_types_${aggregation}`, + timeout: 15_000, + period, + }), + ); + + return result.data.map(row => ({ + coordinate: row.coordinate, + total: ensureNumber(row.total), + })); + } + @traceFn('OperationReader.countCoordinatesOfTarget', { initAttributes: input => ({ 'hive.target.id': input.target, diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index 8b680b37efc..00a15164573 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -4,7 +4,12 @@ import type { SchemaCoordinateStatsResolvers } from './../../../__generated__/ty export const SchemaCoordinateStats: Pick< SchemaCoordinateStatsResolvers, - 'clients' | 'operations' | 'requestsOverTime' | 'totalRequests' + | 'clients' + | 'failuresOverTime' + | 'operations' + | 'requestsOverTime' + | 'totalFailures' + | 'totalRequests' > = { totalRequests: ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { return injector.get(OperationsManager).countRequestsWithSchemaCoordinate({ @@ -15,6 +20,15 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, + totalFailures: ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { + return injector.get(OperationsManager).countFailuresWithSchemaCoordinate({ + organizationId: organization, + projectId: project, + targetId: target, + period, + schemaCoordinate, + }); + }, requestsOverTime: ( { organization, project, target, period, schemaCoordinate }, { resolution }, @@ -96,4 +110,18 @@ export const SchemaCoordinateStats: Pick< }, }; }, + failuresOverTime: ( + { organization, project, target, period, schemaCoordinate }, + { resolution }, + { injector }, + ) => { + return injector.get(OperationsManager).readCoordinateFailuresOverTime({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + schemaCoordinate, + }); + }, }; diff --git a/packages/services/api/src/modules/schema/module.graphql.mappers.ts b/packages/services/api/src/modules/schema/module.graphql.mappers.ts index 0e1e6308931..4cf2a6e06d6 100644 --- a/packages/services/api/src/modules/schema/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/schema/module.graphql.mappers.ts @@ -105,6 +105,7 @@ export type WithSchemaCoordinatesUsage = T & { | PromiseOrValue<{ [coordinate: string]: { total: number; + errorTotal?: number | null; usedByClients: () => PromiseOrValue>; period: DateRange; organizationId: string; @@ -252,6 +253,7 @@ export type SchemaCoordinateUsageMapper = | { isUsed: true; total: number; + errorTotal?: number | null; usedByClients: () => PromiseOrValue>; period: DateRange; organizationId: string; @@ -262,6 +264,7 @@ export type SchemaCoordinateUsageMapper = | { isUsed: false; total: number; + errorTotal?: number | null; usedByClients: () => Array; }; diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index dd03cd28d49..99140559dad 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -252,6 +252,8 @@ export default gql` Whether any subscription operations were reported for this target. """ hasCollectedSubscriptionOperations: Boolean! + + hasFieldLevelMetrics: Boolean! } input SchemaChecksFilter { @@ -1138,6 +1140,11 @@ export default gql` The total amount of usages of the schema coordinate within the contextual period. """ total: Float! + + """ + The total amount of errors of the schema coordinate within the contextual period. + """ + errorTotal: Float """ Whether the schema coordinate is used within the contextual period. """ diff --git a/packages/services/api/src/modules/schema/resolvers/Target.ts b/packages/services/api/src/modules/schema/resolvers/Target.ts index c1bb1694530..50158e4dc24 100644 --- a/packages/services/api/src/modules/schema/resolvers/Target.ts +++ b/packages/services/api/src/modules/schema/resolvers/Target.ts @@ -1,5 +1,6 @@ import { parseDateRangeInput } from '../../../shared/helpers'; import { OperationsManager } from '../../operations/providers/operations-manager'; +import { Storage } from '../../shared/providers/storage'; import { isFieldRequestedDeep } from '../lib/is-field-requested'; import { ContractsManager } from '../providers/contracts-manager'; import { SchemaManager } from '../providers/schema-manager'; @@ -13,6 +14,7 @@ export const Target: Pick< | 'baseSchema' | 'contracts' | 'hasCollectedSubscriptionOperations' + | 'hasFieldLevelMetrics' | 'hasSchema' | 'latestSchemaVersion' | 'latestValidSchemaVersion' @@ -123,4 +125,9 @@ export const Target: Pick< organizationId: target.orgId, }); }, + hasFieldLevelMetrics: async (target, _, { injector }) => { + const org = await injector.get(Storage).getOrganization({ organizationId: target.orgId }); + // @TODO also check if any metrics have been sent with the new format. + return org.featureFlags.subgraphVisibility; + }, }; diff --git a/packages/services/api/src/modules/schema/utils.ts b/packages/services/api/src/modules/schema/utils.ts index f10d96c4451..bb65558d96d 100644 --- a/packages/services/api/src/modules/schema/utils.ts +++ b/packages/services/api/src/modules/schema/utils.ts @@ -370,6 +370,7 @@ export function usage( return { // TODO: This is a hack to mark the field as used but without passing exact number as we don't need the exact number in "Unused schema view". total: 1, + errorTotal: null, isUsed: true, usedByClients: () => [], period: usage.period, @@ -382,6 +383,7 @@ export function usage( return { total: 0, + errorTotal: null, isUsed: false, usedByClients: () => [], }; @@ -393,6 +395,7 @@ export function usage( return coordinateUsage && coordinateUsage.total > 0 ? { total: coordinateUsage.total, + errorTotal: coordinateUsage.errorTotal, isUsed: true, usedByClients: coordinateUsage.usedByClients, period: coordinateUsage.period, @@ -403,6 +406,7 @@ export function usage( } : { total: 0, + errorTotal: null, isUsed: false, usedByClients: () => [], }; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index a5bdd6a9fbd..3be6487d7b3 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -282,6 +282,16 @@ export interface projects { validation_url: string | null; } +export interface proposal_approved_changes { + change: any; + hash: string; + id: string; + proposal_id: string; + schema_version_id: string | null; + service: string | null; + target_id: string; +} + export interface saved_filters { created_at: Date; created_by_user_id: string; @@ -557,6 +567,7 @@ export interface DBTables { organizations: organizations; organizations_billing: organizations_billing; projects: projects; + proposal_approved_changes: proposal_approved_changes; saved_filters: saved_filters; schema_change_approvals: schema_change_approvals; schema_checks: schema_checks; diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index eb61727a830..4d050dad847 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -30,6 +30,7 @@ export function Description(props: { description: string }) { const SchemaExplorerUsageStats_UsageFragment = graphql(` fragment SchemaExplorerUsageStats_UsageFragment on SchemaCoordinateUsage { total + errorTotal isUsed usedByClients topOperations(limit: 5) { @@ -50,6 +51,7 @@ export function SchemaExplorerUsageStats(props: { }) { const usage = useFragment(SchemaExplorerUsageStats_UsageFragment, props.usage); const percentage = props.totalRequests ? (usage.total / props.totalRequests) * 100 : 0; + const availability = usage.errorTotal ? (1.0 - usage.errorTotal / usage.total) * 100.0 : null; const kindLabel = useMemo(() => props.kindLabel ?? 'field', [props.kindLabel]); @@ -59,6 +61,9 @@ export function SchemaExplorerUsageStats(props: {
{formatNumber(usage.total)} + {availability ? ( + ({availability.toFixed(2)}%) + ) : null}
[node.date, node.value]); }, [points]); + + const errorPoints = query.data?.target?.schemaCoordinateStats?.failuresOverTime; + const errorsOverTime = useMemo(() => { + if (!errorPoints) { + return []; + } + + return errorPoints.map(node => [node.date, node.value]); + }, [errorPoints]); const totalRequests = query.data?.target?.schemaCoordinateStats?.totalRequests ?? 0; + const totalFailures = query.data?.target?.schemaCoordinateStats.totalFailures ?? null; const totalOperations = query.data?.target?.schemaCoordinateStats?.operations.edges.length ?? 0; const totalClients = query.data?.target?.schemaCoordinateStats?.clients.edges.length ?? 0; const supergraphMetadata = query.data?.target?.schemaCoordinateStats?.supergraphMetadata; const kind = query.data?.target?.latestValidSchemaVersion?.explorer?.type?.__typename; const title = kind === 'GraphQLEnumType' ? `${typeName} (${props.coordinate})` : props.coordinate; + const hasFieldLevelMetrics = query.data?.target?.hasFieldLevelMetrics; if (query.error) { return ; @@ -193,15 +210,23 @@ function SchemaCoordinateView(props: {
- Total calls + + {hasFieldLevelMetrics ? 'Total resolutions' : 'Total calls'} +
{isLoading ? '-' : formatNumber(totalRequests)} + {totalFailures ? ( + + ({formatNumber(totalFailures)} errors) + + ) : null}

- Requests in {dateRangeController.selectedPreset.label.toLowerCase()} + {hasFieldLevelMetrics ? 'Resolved' : 'Requests'} in{' '} + {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -258,7 +283,9 @@ function SchemaCoordinateView(props: { Activity - GraphQL requests with {props.coordinate} over time + {hasFieldLevelMetrics + ? `Number of times the coordinate ${props.coordinate} has resolved over time` + : `GraphQL requests with ${props.coordinate} over time`} @@ -316,6 +343,21 @@ function SchemaCoordinateView(props: { large: true, data: requestsOverTime, }, + errorsOverTime?.length + ? { + type: 'line', + name: 'Errors', + showSymbol: false, + smooth: false, + color: colors.error, + areaStyle: {}, + emphasis: { + focus: 'series', + }, + large: true, + data: errorsOverTime, + } + : undefined, ], }} /> From eab0624ff2033261110a9646ac11165381bce50c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:48:50 -0700 Subject: [PATCH 27/62] Fix a permissions bug; fix timescale for error count; and use a list for mock --- .../providers/operations-manager.ts | 8 ++++ .../operations/providers/operations-reader.ts | 37 ++++++++++--------- scripts/seed-usage.ts | 11 +++++- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 22339c27f75..911431c1fcd 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -265,6 +265,14 @@ export class OperationsManager { }, }); + const org = await this.storage.getOrganization({ + organizationId, + }); + + if (!org.featureFlags.subgraphVisibility) { + return null; + } + return this.reader .countCoordinateFailure({ targetIds: Array.isArray(targetId) ? targetId : [targetId], diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index b4f303d6ce4..0baa5a4c628 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -368,26 +368,29 @@ export class OperationsReader { `); } + const query = this.pickAggregationByPeriod({ + period, + query: aggregationTableName => sql` + SELECT + coordinate, + sum(total_errors) as total + FROM ${aggregationTableName('coordinate_errors')} + ${this.createFilter({ + target: targetIds, + period, + operations, + extra: conditions, + })} + GROUP BY coordinate + `, + queryId: aggregation => `count_failure_fields_v1_${aggregation}`, + timeout: 30_000, + }); + const res = await this.clickHouse.query<{ total: string; coordinate: string; - }>({ - query: sql` - SELECT - coordinate, - sum(total_errors) as total - FROM coordinate_errors_daily - ${this.createFilter({ - target: targetIds, - period, - operations, - extra: conditions, - })} - GROUP BY coordinate - `, - queryId: 'count_failure_fields_v1', - timeout: 30_000, - }); + }>(query); const stats: Record = {}; for (const row of res.data) { diff --git a/scripts/seed-usage.ts b/scripts/seed-usage.ts index bb82597cce6..401375635f6 100644 --- a/scripts/seed-usage.ts +++ b/scripts/seed-usage.ts @@ -7,7 +7,7 @@ import { buildSchema, DocumentNode, graphql, GraphQLSchema, parse, print } from import { createHive, HiveClient } from '@graphql-hive/core'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { loadDocuments, loadSchema, loadTypedefs } from '@graphql-tools/load'; -import { addMocksToSchema } from '@graphql-tools/mock'; +import { addMocksToSchema, MockList } from '@graphql-tools/mock'; import { composeServices, ServiceDefinition, @@ -205,7 +205,14 @@ if (isFederation === false) { }); const schema = buildSchema(apiSchema); - const schemaWithMocks = addMocksToSchema({ schema }); + const schemaWithMocks = addMocksToSchema({ + schema, + mocks: { + Query: { + allProducts: () => new MockList(5), + }, + }, + }); start({ instance, schema: schemaWithMocks, queries }); } From 762a153e9c8ea02b74caf23440c00d3c9b253377 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:37:53 -0700 Subject: [PATCH 28/62] Fix resolution count total and over time --- packages/libraries/core/src/client/usage.ts | 2 +- .../providers/operations-manager.ts | 38 ++++++- .../operations/providers/operations-reader.ts | 98 +++++++++++++++++-- .../resolvers/SchemaCoordinateStats.ts | 2 +- scripts/seed-usage.ts | 30 ++++++ 5 files changed, 157 insertions(+), 13 deletions(-) diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index 4dfad4c166f..e0bb83144de 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -313,7 +313,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ok: !result.errors?.length, duration: args.duration, errorsTotal: result.errors?.length ?? 0, - fetches: args.fetches, + fetches, }, // TODO: operationHash is ready to accept hashes of persisted operations client: args.experimental__persistedDocumentHash diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 911431c1fcd..a267debb530 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -237,13 +237,18 @@ export class OperationsManager { }, }); + const org = await this.storage.getOrganization({ + organizationId, + }); + return this.reader - .countRequests({ - target: targetId, + .countCoordinate({ + targetIds: Array.isArray(targetId) ? targetId : [targetId], period, schemaCoordinate, + aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', }) - .then(r => r.total); + .then(r => r[schemaCoordinate]); } async countFailuresWithSchemaCoordinate({ @@ -779,6 +784,33 @@ export class OperationsManager { }); } + async readCoordinatesOverTime({ + targetId, + organizationId, + period, + resolution, + schemaCoordinate, + }: { + period: DateRange; + resolution: number; + schemaCoordinate: string; + } & TargetSelector) { + const org = await this.storage.getOrganization({ + organizationId, + }); + + if (org.featureFlags.subgraphVisibility !== true) { + return []; + } + + return this.reader.getCoordinatesOverTime({ + period, + resolution, + schemaCoordinate, + targetId, + }); + } + async readCoordinateFailuresOverTime({ targetId, organizationId, diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 0baa5a4c628..d7e1554b2d2 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -206,11 +206,12 @@ export class OperationsReader { period: DateRange; operations?: readonly string[]; excludedClients?: readonly string[] | null; + aggregateSource?: 'coordinate_counts' | 'coordinates'; }, Record >( item => - `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}`, + `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}-${item.aggregateSource}`, async items => { const schemaCoordinates = items.map(item => item.schemaCoordinate); return await this.countCoordinates({ @@ -219,6 +220,7 @@ export class OperationsReader { period: items[0].period, operations: items[0].operations, schemaCoordinates, + aggregateSource: items[0].aggregateSource, }).then(result => items.map(item => Promise.resolve({ [item.schemaCoordinate]: result[item.schemaCoordinate] }), @@ -227,18 +229,25 @@ export class OperationsReader { }, ); + /** + * Can count the number of requests hitting a coordinate or the number of resolutions depending on the aggregateSource. + * By default, this will return the number of requests. Pass the aggregate source "coordinate_counts" to get the total + * count of resolutions for a coordinate. + */ public async countCoordinates({ schemaCoordinates, targetIds, period, operations, excludedClients, + aggregateSource = 'coordinates', }: { schemaCoordinates: readonly string[]; targetIds: string | readonly string[]; period: DateRange; operations?: readonly string[]; excludedClients?: readonly string[] | null; + aggregateSource?: 'coordinate_counts' | 'coordinates'; }) { const conditions = [sql`(coordinate IN (${sql.array(schemaCoordinates, 'String')}))`]; @@ -266,15 +275,13 @@ export class OperationsReader { `); } - const res = await this.clickHouse.query<{ - total: string; - coordinate: string; - }>({ - query: sql` + const query = this.pickAggregationByPeriod({ + period, + query: aggregationTableName => sql` SELECT coordinate, sum(total) as total - FROM coordinates_daily + FROM ${aggregationTableName(aggregateSource)} ${this.createFilter({ target: targetIds, period, @@ -283,10 +290,15 @@ export class OperationsReader { })} GROUP BY coordinate `, - queryId: 'count_fields_v2', + queryId: aggregation => `count_fields_v2_${aggregation}`, timeout: 30_000, }); + const res = await this.clickHouse.query<{ + total: string; + coordinate: string; + }>(query); + const stats: Record = {}; for (const row of res.data) { stats[row.coordinate] = ensureNumber(row.total); @@ -2115,6 +2127,76 @@ export class OperationsReader { return result.data.map(row => row.client_name); } + async getCoordinatesOverTime({ + targetId, + period, + resolution, + schemaCoordinate, + }: { + targetId: string; + period: DateRange; + resolution: number; + schemaCoordinate: string; + }) { + const interval = calculateTimeWindow({ period, resolution }); + const intervalRaw = this.clickHouse.translateWindow(interval); + const roundedPeriod = { + from: toStartOfInterval(period.from, interval.value, interval.unit), + to: toEndOfInterval(period.to, interval.value, interval.unit), + }; + const startDateTimeFormatted = formatDate(roundedPeriod.from); + const endDateTimeFormatted = formatDate(roundedPeriod.to); + + const query = this.pickAggregationByPeriod({ + timeout: 15_000, + period, + resolution, + queryId: aggregation => `coord_count_over_time_${aggregation}`, + query: aggregationTableName => { + return sql` + SELECT + date, + total, + FROM ( + SELECT + toDateTime( + intDiv( + toUnixTimestamp(timestamp), + toUInt32(${String(interval.seconds)}) + ) * toUInt32(${String(interval.seconds)}) + ) as date, + sum(total) as total + FROM ${aggregationTableName('coordinate_counts')} + ${this.createFilter({ + target: targetId, + period: roundedPeriod, + extra: [sql`coordinate = ${schemaCoordinate}`], + })} + GROUP BY date + ORDER BY date + WITH FILL + FROM toDateTime(${startDateTimeFormatted}, 'UTC') + TO toDateTime(${endDateTimeFormatted}, 'UTC') + STEP INTERVAL ${intervalRaw} + ) + `; + }, + }); + + // multiply by 1000 to convert to milliseconds + const result = await this.clickHouse.query<{ + date: string; + total: number; + }>(query); + + return result.data.map(row => { + return { + date: toUnixTimestamp(row.date), + value: ensureNumber(row.total), + }; + }); + } + async getCoordinateFailuresOverTime({ targetId, period, diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index 00a15164573..f1ffeaa65ca 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -34,7 +34,7 @@ export const SchemaCoordinateStats: Pick< { resolution }, { injector }, ) => { - return injector.get(OperationsManager).readRequestsOverTime({ + return injector.get(OperationsManager).readCoordinatesOverTime({ targetId: target, projectId: project, organizationId: organization, diff --git a/scripts/seed-usage.ts b/scripts/seed-usage.ts index 401375635f6..e029006af40 100644 --- a/scripts/seed-usage.ts +++ b/scripts/seed-usage.ts @@ -2,6 +2,7 @@ * Example: * `TARGET= FEDERATION=1 STAGE=local TOKEN= pnpm seed:usage` */ +import { randomUUID } from 'crypto'; import { parse as parsePath } from 'path'; import { buildSchema, DocumentNode, graphql, GraphQLSchema, parse, print } from 'graphql'; import { createHive, HiveClient } from '@graphql-hive/core'; @@ -211,6 +212,11 @@ if (isFederation === false) { Query: { allProducts: () => new MockList(5), }, + Product: { + variations: () => new MockList(2), + upc: generateUpc, + }, + ID: () => randomUUID(), }, }); start({ instance, schema: schemaWithMocks, queries }); @@ -227,3 +233,27 @@ function getDocumentRoot(documentNode: DocumentNode): string[] | null { .filter(selection => selection.kind === 'Field') .map(selection => selection.name.value); } + +function generateUpc() { + let upc = ''; + for (let i = 0; i < 11; i++) { + upc += Math.floor(Math.random() * 10); + } + let oddSum = 0; + let evenSum = 0; + + for (let i = 0; i < 11; i++) { + const digit = parseInt(upc[i], 10); + if (i % 2 === 0) { + oddSum += digit; // Odd positions (0, 2, 4, 6, 8, 10) are 1-based odd + } else { + evenSum += digit; // Even positions (1, 3, 5, 7, 9) are 1-based even + } + } + + const total = oddSum * 3 + evenSum; + const remainder = total % 10; + const checksum = remainder === 0 ? 0 : 10 - remainder; + + return upc + checksum; +} From 2afc8d41609938da2777148484e86788f322dd12 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:15:25 -0700 Subject: [PATCH 29/62] Track type usage --- .../client/subrequests/extract-coordinates.ts | 131 +++++++++++------- 1 file changed, 81 insertions(+), 50 deletions(-) diff --git a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts index 1debfba0d9d..065d72c41af 100644 --- a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts +++ b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts @@ -1,5 +1,7 @@ import { getNamedType, + GraphQLNamedType, + GraphQLObjectType, isInterfaceType, isObjectType, isUnionType, @@ -11,112 +13,141 @@ import { } from 'graphql'; /** - * Extracts true schema coordinates and counts their execution volume. + * Extracts true schema coordinates and counts their execution volume, + * including resolved types. */ export function extractCoordinates( schema: GraphQLSchema, document: DocumentNode, resultData: any, ): Record { - const counts: Record = {}; + // Optimization 1: Use a prototype-less object for faster dictionary I/O + const counts: Record = Object.create(null); - // 1. Find the root operation (Query, Mutation, Subscription) const operation = document.definitions.find( (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition', ); if (!operation) return counts; - // 2. Determine the root schema type - let rootType; + let rootType: GraphQLObjectType | undefined | null; if (operation.operation === 'query') rootType = schema.getQueryType(); else if (operation.operation === 'mutation') rootType = schema.getMutationType(); else if (operation.operation === 'subscription') rootType = schema.getSubscriptionType(); if (!rootType || !resultData) return counts; - // 3. Start the synchronized walk - walkZip(resultData, operation.selectionSet.selections, rootType, schema, counts); + // Optimization 2: Cache schema lookups to avoid Map lookups on repeated fragments + const typeCache: Record = Object.create(null); + const getType = (name: string): GraphQLType | undefined => { + if (!typeCache[name]) { + const type = schema.getType(name); + if (type) typeCache[name] = type; + } + return typeCache[name]; + }; + + // Start the synchronized walk + walkNode(resultData, operation.selectionSet.selections, rootType, counts, getType); return counts; } -function walkZip( +/** + * walkNode: Responsible for handling nulls, unwrapping types, and array iteration. + */ +function walkNode( data: any, selections: readonly SelectionNode[], parentType: GraphQLType, - schema: GraphQLSchema, counts: Record, + getType: (name: string) => GraphQLType | undefined, ) { - if (Array.isArray(data)) { - for (const item of data) { - walkZip(item, selections, parentType, schema, counts); - } + if (data === null || typeof data !== 'object') return; + + // Optimization 3: Un-wrap the type ONCE before dealing with arrays + const namedType = getNamedType(parentType); + if (!isObjectType(namedType) && !isInterfaceType(namedType) && !isUnionType(namedType)) { return; } - if (data === null || typeof data !== 'object') { + if (Array.isArray(data)) { + // Optimization 4: Loop the array and send it directly to the object walker. + // This bypasses `getNamedType` and array-checking for every single element. + for (let i = 0; i < data.length; i++) { + const item = data[i]; + if (item !== null && typeof item === 'object') { + walkObject(item, selections, namedType, counts, getType, false); + } + } return; } - const namedType = getNamedType(parentType); - if (!isObjectType(namedType) && !isInterfaceType(namedType) && !isUnionType(namedType)) { - return; + walkObject(data, selections, namedType, counts, getType, false); +} + +/** + * walkObject: Highly optimized hot-loop for standard objects and selections. + */ +function walkObject( + data: any, + selections: readonly SelectionNode[], + namedType: GraphQLNamedType, + counts: Record, + getType: (name: string) => GraphQLType | undefined, + isFragmentRecurse: boolean, +) { + const namedTypeName = namedType.name; + const runtimeTypeName = data.__typename || namedTypeName; + + if (!isFragmentRecurse) { + counts[runtimeTypeName] = (counts[runtimeTypeName] || 0) + 1; } - // Get fields, but safely handle Union types which don't have direct fields - const fields = 'getFields' in namedType ? namedType.getFields() : {}; + let fields: any = null; + + for (let i = 0; i < selections.length; i++) { + const selection = selections[i]; - for (const selection of selections) { if (selection.kind === 'Field') { const realFieldName = selection.name.value; - const responseKey = selection.alias ? selection.alias.value : realFieldName; + if (realFieldName === '__typename') continue; - // Special case for __typename which isn't in the schema fields map - if (realFieldName === '__typename') { - continue; - } + const responseKey = selection.alias ? selection.alias.value : realFieldName; + const val = data[responseKey]; - if (responseKey in data) { - const coordinate = `${namedType.name}.${realFieldName}`; + if (val !== undefined) { + // Optimization 5: String concatenation over template literals + const coordinate = namedTypeName + '.' + realFieldName; counts[coordinate] = (counts[coordinate] || 0) + 1; - if (selection.selectionSet && fields[realFieldName]) { - walkZip( - data[responseKey], - selection.selectionSet.selections, - fields[realFieldName].type, - schema, - counts, - ); + if (selection.selectionSet && val !== null) { + if (!fields) { + fields = 'getFields' in namedType ? (namedType as any).getFields() : {}; + } + + const fieldDef = fields[realFieldName]; + if (fieldDef) { + // We drop back down to walkNode because `val` might be an array or null + walkNode(val, selection.selectionSet.selections, fieldDef.type, counts, getType); + } } } } else if (selection.kind === 'InlineFragment') { const typeConditionName = selection.typeCondition?.name.value; - let matchesType = true; let nextType: GraphQLType = namedType; if (typeConditionName) { - // In federated execution, abstract types (Interfaces/Unions) generally - // return __typename in the payload. We use that to verify the fragment match. - const runtimeTypeName = data.__typename || namedType.name; matchesType = typeConditionName === runtimeTypeName; - if (matchesType) { - nextType = schema.getType(typeConditionName) || namedType; + nextType = getType(typeConditionName) || namedType; } } - // If the runtime data matches the fragment's type condition, - // recurse using the SAME data node, but the fragment's selection set. if (matchesType && selection.selectionSet) { - walkZip( - data, // <-- Pass the exact same data object - selection.selectionSet.selections, - nextType, // <-- Pass the narrowed type - schema, - counts, - ); + // Optimization 6: Because fragments resolve on the SAME object, + // we skip walkNode entirely and recurse directly into walkObject. + walkObject(data, selection.selectionSet.selections, nextType, counts, getType, true); } } } From 16a8ed2701dc247c9db8942bf558c9aa508ec83a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:26:08 -0700 Subject: [PATCH 30/62] Dont lookup errors on types and scalars; fix typing on extract coordinates --- .../core/src/client/subrequests/extract-coordinates.ts | 10 +++++----- .../operations/resolvers/SchemaCoordinateStats.ts | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts index 065d72c41af..9491790c15a 100644 --- a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts +++ b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts @@ -37,8 +37,8 @@ export function extractCoordinates( if (!rootType || !resultData) return counts; // Optimization 2: Cache schema lookups to avoid Map lookups on repeated fragments - const typeCache: Record = Object.create(null); - const getType = (name: string): GraphQLType | undefined => { + const typeCache: Record = Object.create(null); + const getType = (name: string): GraphQLNamedType | undefined => { if (!typeCache[name]) { const type = schema.getType(name); if (type) typeCache[name] = type; @@ -60,7 +60,7 @@ function walkNode( selections: readonly SelectionNode[], parentType: GraphQLType, counts: Record, - getType: (name: string) => GraphQLType | undefined, + getType: (name: string) => GraphQLNamedType | undefined, ) { if (data === null || typeof data !== 'object') return; @@ -93,7 +93,7 @@ function walkObject( selections: readonly SelectionNode[], namedType: GraphQLNamedType, counts: Record, - getType: (name: string) => GraphQLType | undefined, + getType: (name: string) => GraphQLNamedType | undefined, isFragmentRecurse: boolean, ) { const namedTypeName = namedType.name; @@ -135,7 +135,7 @@ function walkObject( } else if (selection.kind === 'InlineFragment') { const typeConditionName = selection.typeCondition?.name.value; let matchesType = true; - let nextType: GraphQLType = namedType; + let nextType: GraphQLNamedType = namedType; if (typeConditionName) { matchesType = typeConditionName === runtimeTypeName; diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index f1ffeaa65ca..e19da25bf28 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -21,6 +21,10 @@ export const SchemaCoordinateStats: Pick< }); }, totalFailures: ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { + // Failures are tracked to fields and not types. Don't bother doing a lookup for types. + if (!schemaCoordinate.includes('.')) { + return null; + } return injector.get(OperationsManager).countFailuresWithSchemaCoordinate({ organizationId: organization, projectId: project, From fb94bf29efc8c38fdf6af991c619e6b63baacc8b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:53:51 -0700 Subject: [PATCH 31/62] add test for operation error serialization --- .../__tests__/serializer.spec.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/services/usage-ingestor/__tests__/serializer.spec.ts b/packages/services/usage-ingestor/__tests__/serializer.spec.ts index 18027a7eae8..a25742e7f7f 100644 --- a/packages/services/usage-ingestor/__tests__/serializer.spec.ts +++ b/packages/services/usage-ingestor/__tests__/serializer.spec.ts @@ -1,6 +1,8 @@ +import { ProcessedOperationErrorRecord } from '@hive/usage-common'; import { formatDate, joinIntoSingleMessage, + stringifyOperationErrors, stringifyQueryOrMutationOperation, stringifyRegistryRecord, } from '../src/serializer'; @@ -72,7 +74,7 @@ test('stringify operation in correct format and order', () => { /* duration */ 230, /* client_name */ `"clientName"`, /* client_version */ `"clientVersion"`, - /* coordinates_total */ `"{""foo"":3}"`, + /* coordinates_total */ `"{'foo':3}"`, ].join(','), [ /* organization */ `"my-organization"`, @@ -85,7 +87,7 @@ test('stringify operation in correct format and order', () => { /* duration */ 250, /* client_name */ `\\N`, /* client_version */ `\\N`, - /* coordinates_total */ `"{""foo"":1}"`, + /* coordinates_total */ `"{'foo':1}"`, ].join(','), ].join('\n'), ); @@ -146,6 +148,46 @@ test('stringify registry records in correct format and order', () => { ); }); +test('stringify operation_errors in correct format and order', () => { + const messages: ProcessedOperationErrorRecord[] = [ + { + target: 'my-target', + hash: 'my-hash-1', + timestamp: timestamp.asNumber, + expires_at: expiresAt.asNumber, + errors: [['UNEXPECTED_ERROR', 'Query.foo'] as [code: string, coord: string]], + }, + { + target: 'my-target', + hash: 'my-hash-2', + timestamp: timestamp.asNumber, + expires_at: expiresAt.asNumber, + errors: [['UNEXPECTED_ERROR', 'Query.user'] as [code: string, coord: string]], + }, + ]; + const serialized = joinIntoSingleMessage(messages.map(stringifyOperationErrors)); + expect(serialized).toBe( + [ + [ + `"my-target"`, + `"my-hash-1"`, + timestamp.asString, + expiresAt.asString, + `"[('UNEXPECTED_ERROR','Query.foo')]"`, + ], + [ + `"my-target"`, + `"my-hash-2"`, + timestamp.asString, + expiresAt.asString, + `"[('UNEXPECTED_ERROR','Query.user')]"`, + ], + ] + .map(r => r.join(',')) + .join('\n'), + ); +}); + test('formatDate should return formatted date in UTC timezone', () => { expect(formatDate(timestamp.asNumber)).toEqual(timestamp.asString); }); From 7fea2554b73d5a3be6b5c011238db26dce4461c6 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:57:07 -0700 Subject: [PATCH 32/62] Don't do lookup on errors over time on types --- .../modules/operations/resolvers/SchemaCoordinateStats.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index e19da25bf28..eb011a3e549 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -119,6 +119,11 @@ export const SchemaCoordinateStats: Pick< { resolution }, { injector }, ) => { + // Failures are tracked to fields and not types. Don't bother doing a lookup for types. + if (!schemaCoordinate.includes('.')) { + return null; + } + return injector.get(OperationsManager).readCoordinateFailuresOverTime({ targetId: target, projectId: project, From e1440482f9ddd31e44ef5728f2525347bc4cd72e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:07:32 -0700 Subject: [PATCH 33/62] Improve metrics on hover for explorer --- .../app/src/components/target/explorer/common.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 4d050dad847..16774325fe9 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -51,7 +51,9 @@ export function SchemaExplorerUsageStats(props: { }) { const usage = useFragment(SchemaExplorerUsageStats_UsageFragment, props.usage); const percentage = props.totalRequests ? (usage.total / props.totalRequests) * 100 : 0; - const availability = usage.errorTotal ? (1.0 - usage.errorTotal / usage.total) * 100.0 : null; + const availability = usage.errorTotal + ? ((1.0 - usage.errorTotal / usage.total) * 100.0).toFixed(2) + : null; const kindLabel = useMemo(() => props.kindLabel ?? 'field', [props.kindLabel]); @@ -59,11 +61,12 @@ export function SchemaExplorerUsageStats(props: {
-
+
{formatNumber(usage.total)} - {availability ? ( - ({availability.toFixed(2)}%) - ) : null} + {availability ? ({availability}%) : null}
Date: Thu, 4 Jun 2026 18:28:44 -0700 Subject: [PATCH 34/62] Better tooltips --- packages/web/app/src/components/target/explorer/common.tsx | 2 +- packages/web/app/src/pages/target-insights-coordinate.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 16774325fe9..43283c093fc 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -63,7 +63,7 @@ export function SchemaExplorerUsageStats(props: {
{formatNumber(usage.total)} {availability ? ({availability}%) : null} diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index a32313df8ba..898d491ed1e 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -209,7 +209,12 @@ function SchemaCoordinateView(props: {
- + {hasFieldLevelMetrics ? 'Total resolutions' : 'Total calls'} From 75736228398ba3f54a21a74434324326eeaa677c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:35:39 -0700 Subject: [PATCH 35/62] Handle abstract types --- .../client/subrequests/extract-coordinates.ts | 221 ++++++-- packages/libraries/core/src/client/usage.ts | 2 +- .../subrequests/extract-coordinates.spec.ts | 198 +++++++ .../gateway-usage/src/add-typenames.ts | 172 ++++++ packages/libraries/gateway-usage/src/index.ts | 6 + .../gateway-usage/tests/add-typenames.spec.ts | 512 ++++++++++++++++++ 6 files changed, 1076 insertions(+), 35 deletions(-) create mode 100644 packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts create mode 100644 packages/libraries/gateway-usage/src/add-typenames.ts create mode 100644 packages/libraries/gateway-usage/tests/add-typenames.spec.ts diff --git a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts index 9491790c15a..0e3bcb38841 100644 --- a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts +++ b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts @@ -1,11 +1,15 @@ import { getNamedType, - GraphQLNamedType, - GraphQLObjectType, + GraphQLFieldMap, + GraphQLInputFieldMap, + GraphQLResolveInfo, isInterfaceType, isObjectType, isUnionType, type DocumentNode, + type FragmentDefinitionNode, + type GraphQLNamedType, + type GraphQLObjectType, type GraphQLSchema, type GraphQLType, type OperationDefinitionNode, @@ -14,29 +18,39 @@ import { /** * Extracts true schema coordinates and counts their execution volume, - * including resolved types. + * including resolved types, aliases, unions, interfaces, and scalars. */ export function extractCoordinates( schema: GraphQLSchema, document: DocumentNode, resultData: any, + /** Used to resolve an abstract type's real definition */ + contextValue?: any, + infoValue?: GraphQLResolveInfo, ): Record { - // Optimization 1: Use a prototype-less object for faster dictionary I/O const counts: Record = Object.create(null); - const operation = document.definitions.find( - (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition', - ); - if (!operation) return counts; + let operation: OperationDefinitionNode | undefined; + const fragments: Record = Object.create(null); + + for (let i = 0; i < document.definitions.length; i++) { + const def = document.definitions[i]; + if (def.kind === 'OperationDefinition' && !operation) { + operation = def; + } else if (def.kind === 'FragmentDefinition') { + fragments[def.name.value] = def; + } + } + + if (!operation || !resultData) return counts; let rootType: GraphQLObjectType | undefined | null; if (operation.operation === 'query') rootType = schema.getQueryType(); else if (operation.operation === 'mutation') rootType = schema.getMutationType(); else if (operation.operation === 'subscription') rootType = schema.getSubscriptionType(); - if (!rootType || !resultData) return counts; + if (!rootType) return counts; - // Optimization 2: Cache schema lookups to avoid Map lookups on repeated fragments const typeCache: Record = Object.create(null); const getType = (name: string): GraphQLNamedType | undefined => { if (!typeCache[name]) { @@ -46,64 +60,138 @@ export function extractCoordinates( return typeCache[name]; }; - // Start the synchronized walk - walkNode(resultData, operation.selectionSet.selections, rootType, counts, getType); + const matchCache: Record = Object.create(null); + const doesTypeMatch = (runtimeTypeName: string, typeConditionName: string): boolean => { + if (runtimeTypeName === typeConditionName) return true; + + const cacheKey = runtimeTypeName + '|' + typeConditionName; + if (matchCache[cacheKey] !== undefined) return matchCache[cacheKey]; + + const runtimeType = getType(runtimeTypeName); + const conditionType = getType(typeConditionName); + + let isMatch = false; + if (runtimeType && conditionType) { + if (isInterfaceType(conditionType) && isObjectType(runtimeType)) { + isMatch = runtimeType.getInterfaces().some(i => i.name === conditionType.name); + } else if (isUnionType(conditionType) && isObjectType(runtimeType)) { + isMatch = conditionType.getTypes().some(t => t.name === runtimeType.name); + } + } + + matchCache[cacheKey] = isMatch; + return isMatch; + }; + + walkNode( + resultData, + operation.selectionSet.selections, + rootType, + counts, + getType, + doesTypeMatch, + fragments, + contextValue, + infoValue, + ); return counts; } -/** - * walkNode: Responsible for handling nulls, unwrapping types, and array iteration. - */ function walkNode( data: any, selections: readonly SelectionNode[], parentType: GraphQLType, counts: Record, getType: (name: string) => GraphQLNamedType | undefined, + doesTypeMatch: (runtime: string, condition: string) => boolean, + fragments: Record, + contextValue?: any, + infoValue?: GraphQLResolveInfo, ) { if (data === null || typeof data !== 'object') return; - // Optimization 3: Un-wrap the type ONCE before dealing with arrays const namedType = getNamedType(parentType); if (!isObjectType(namedType) && !isInterfaceType(namedType) && !isUnionType(namedType)) { return; } if (Array.isArray(data)) { - // Optimization 4: Loop the array and send it directly to the object walker. - // This bypasses `getNamedType` and array-checking for every single element. for (let i = 0; i < data.length; i++) { const item = data[i]; if (item !== null && typeof item === 'object') { - walkObject(item, selections, namedType, counts, getType, false); + walkObject( + item, + selections, + namedType, + counts, + getType, + doesTypeMatch, + fragments, + false, + contextValue, + infoValue, + ); } } return; } - walkObject(data, selections, namedType, counts, getType, false); + walkObject( + data, + selections, + namedType, + counts, + getType, + doesTypeMatch, + fragments, + false, + contextValue, + infoValue, + ); } -/** - * walkObject: Highly optimized hot-loop for standard objects and selections. - */ function walkObject( data: any, selections: readonly SelectionNode[], namedType: GraphQLNamedType, counts: Record, getType: (name: string) => GraphQLNamedType | undefined, + doesTypeMatch: (runtime: string, condition: string) => boolean, + fragments: Record, isFragmentRecurse: boolean, + contextValue?: any, + infoValue?: GraphQLResolveInfo, ) { const namedTypeName = namedType.name; - const runtimeTypeName = data.__typename || namedTypeName; + let runtimeTypeName = data.__typename; + const isAbstractType = isInterfaceType(namedType) || isUnionType(namedType); + + // Use resolveType for abstract types if __typename is missing from the payload + if (!runtimeTypeName && isAbstractType) { + /** + * Note that this should be a fallback for an extreme edge case. Because it's very unreliable. + * Abstract types cannot be determined in the gateway to be of a specific other type because the resolveType function + * won't have been implemented. However, this may solve the case in a monorepo. + */ + runtimeTypeName = + (infoValue && namedType.resolveType?.(data, contextValue, infoValue, namedType)) ?? + namedTypeName; + } + + runtimeTypeName = runtimeTypeName || namedTypeName; + + /** Track the abstract type as having been used */ + if (runtimeTypeName !== namedTypeName && isAbstractType) { + counts[namedTypeName] = (counts[namedTypeName] || 0) + 1; + } if (!isFragmentRecurse) { + // This accurately registers the resolved implementation to the counts map counts[runtimeTypeName] = (counts[runtimeTypeName] || 0) + 1; } - let fields: any = null; + let fields: GraphQLFieldMap | GraphQLInputFieldMap | null = null; for (let i = 0; i < selections.length; i++) { const selection = selections[i]; @@ -116,19 +204,44 @@ function walkObject( const val = data[responseKey]; if (val !== undefined) { - // Optimization 5: String concatenation over template literals const coordinate = namedTypeName + '.' + realFieldName; counts[coordinate] = (counts[coordinate] || 0) + 1; - if (selection.selectionSet && val !== null) { + if (val !== null) { if (!fields) { - fields = 'getFields' in namedType ? (namedType as any).getFields() : {}; + fields = 'getFields' in namedType ? namedType.getFields() : {}; } const fieldDef = fields[realFieldName]; if (fieldDef) { - // We drop back down to walkNode because `val` might be an array or null - walkNode(val, selection.selectionSet.selections, fieldDef.type, counts, getType); + if (selection.selectionSet) { + walkNode( + val, + selection.selectionSet.selections, + fieldDef.type, + counts, + getType, + doesTypeMatch, + fragments, + contextValue, + infoValue, + ); + } else { + // Feature: Extract and count Leaf Types (Scalars / Enums) + const leafTypeName = getNamedType(fieldDef.type)?.name; + + if (Array.isArray(val)) { + let leafCount = 0; + for (let j = 0; j < val.length; j++) { + if (val[j] !== null) leafCount++; + } + if (leafCount > 0) { + counts[leafTypeName] = (counts[leafTypeName] || 0) + leafCount; + } + } else { + counts[leafTypeName] = (counts[leafTypeName] || 0) + 1; + } + } } } } @@ -138,16 +251,56 @@ function walkObject( let nextType: GraphQLNamedType = namedType; if (typeConditionName) { - matchesType = typeConditionName === runtimeTypeName; + matchesType = doesTypeMatch(runtimeTypeName, typeConditionName); if (matchesType) { nextType = getType(typeConditionName) || namedType; } } if (matchesType && selection.selectionSet) { - // Optimization 6: Because fragments resolve on the SAME object, - // we skip walkNode entirely and recurse directly into walkObject. - walkObject(data, selection.selectionSet.selections, nextType, counts, getType, true); + walkObject( + data, + selection.selectionSet.selections, + nextType, + counts, + getType, + doesTypeMatch, + fragments, + true, + contextValue, + infoValue, + ); + } + } else if (selection.kind === 'FragmentSpread') { + const fragmentName = selection.name.value; + const fragmentDef = fragments[fragmentName]; + + if (fragmentDef) { + const typeConditionName = fragmentDef.typeCondition.name.value; + let matchesType = true; + let nextType: GraphQLNamedType = namedType; + + if (typeConditionName) { + matchesType = doesTypeMatch(runtimeTypeName, typeConditionName); + if (matchesType) { + nextType = getType(typeConditionName) || namedType; + } + } + + if (matchesType && fragmentDef.selectionSet) { + walkObject( + data, + fragmentDef.selectionSet.selections, + nextType, + counts, + getType, + doesTypeMatch, + fragments, + true, + contextValue, + infoValue, + ); + } } } } diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index e0bb83144de..a7f76496f18 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -294,7 +294,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl subgraphSchema: args.args.schema, type: 'ROOT', paths: rootOperation.operation, - result, + result, // make sure this isnt taking too much memory to store. Can this be stripped out? }, ]; } diff --git a/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts b/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts new file mode 100644 index 00000000000..28160216ead --- /dev/null +++ b/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts @@ -0,0 +1,198 @@ +import { buildSchema, parse } from 'graphql'; +import { extractCoordinates } from '../../../src/client/subrequests/extract-coordinates.js'; + +describe('extractCoordinates', () => { + describe('Types, Fields, and Scalars', () => { + it('Counts standard object resolution and scalar fields', () => { + const schema = buildSchema(` + type Query { user: User } + type User { id: ID, name: String, age: Int } + `); + const document = parse(` + query { + user { id name age } + } + `); + const resultData = { + user: { id: '1', name: 'Alice', age: 30 }, + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + ID: 1, + Int: 1, + Query: 1, + 'Query.user': 1, + String: 1, + User: 1, + 'User.id': 1, + 'User.name': 1, + 'User.age': 1, + }); + }); + }); + + describe('Aliases', () => { + it('Resolves the response key back to the true schema coordinate', () => { + const schema = buildSchema(` + type Query { user: User } + type User { id: ID, name: String } + `); + const document = parse(` + query { + firstUser: user { + userId: id + userName: name + } + } + `); + const resultData = { + firstUser: { userId: '1', userName: 'Alice' }, + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + ID: 1, + Query: 1, + 'Query.user': 1, + String: 1, + User: 1, + 'User.id': 1, + 'User.name': 1, + }); + }); + }); + + describe('Interfaces & Inline Fragments', () => { + it('Structurally verifies interface inheritance', () => { + const schema = buildSchema(` + interface Node { id: ID! } + type User implements Node { id: ID!, name: String } + type Query { node: Node } + `); + const document = parse(` + query { + node { + id + ... on User { name } + } + } + `); + const resultData = { + node: { __typename: 'User', id: '1', name: 'Alice' }, + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + ID: 1, + Query: 1, + Node: 1, + 'Query.node': 1, + String: 1, + User: 1, + 'Node.id': 1, + 'User.name': 1, + }); + }); + }); + + describe('Unions & Arrays', () => { + it('Handles list execution and union type conditions', () => { + const schema = buildSchema(` + union SearchResult = User | Post + type User { name: String } + type Post { title: String } + type Query { search: [SearchResult] } + `); + const document = parse(` + query { + search { + ... on User { name } + ... on Post { __typename, title } + } + } + `); + const resultData = { + search: [{ name: 'Alice' }, { __typename: 'Post', title: 'GraphQL Guide' }], + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + Query: 1, + 'Query.search': 1, + String: 1, + Post: 1, + 'Post.title': 1, + SearchResult: 2, // the abstract type returned by Query.search + /** + * Note that ideally this would match: + * String: 2, + * User: 1, + * 'User.name': 1, + * but because the user doesn't return the typename, we can't be sure of anything. + * This is a hard limit of this approach. It's overcome by including the __typename + * on every abstract type in an operation, which needs implemented by the plugin. + */ + }); + }); + }); + + describe('Fragment Spreads', () => { + it('Follows named fragment definitions', () => { + const schema = buildSchema(` + type Query { user: User } + type User { id: ID, email: String } + `); + const document = parse(` + query { + user { ...UserFields } + } + fragment UserFields on User { + id + email + } + `); + const resultData = { + user: { id: '1', email: 'test@example.com' }, + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + ID: 1, + Query: 1, + 'Query.user': 1, + String: 1, + User: 1, + 'User.id': 1, + 'User.email': 1, + }); + }); + }); + + describe('Null Handling', () => { + it('Gracefully skips unresolvable or null fields', () => { + const schema = buildSchema(` + type Query { user: User } + type User { id: ID, name: String } + `); + const document = parse(` + query { user { id name } } + `); + const resultData = { + user: null, + }; + + const counts = extractCoordinates(schema, document, resultData); + + expect(counts).toEqual({ + Query: 1, + 'Query.user': 1, + }); + }); + }); +}); diff --git a/packages/libraries/gateway-usage/src/add-typenames.ts b/packages/libraries/gateway-usage/src/add-typenames.ts new file mode 100644 index 00000000000..9a4664bf701 --- /dev/null +++ b/packages/libraries/gateway-usage/src/add-typenames.ts @@ -0,0 +1,172 @@ +import { + getNamedType, + isAbstractType, + isCompositeType, + isObjectType, + Kind, + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + type DocumentNode, + type FieldNode, + type GraphQLCompositeType, + type GraphQLSchema, + type InlineFragmentNode, + type SelectionSetNode, +} from 'graphql'; + +const TYPENAME_FIELD: FieldNode = { + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: '__typename' }, +}; + +/** + * Recursively adds __typename to every selection set whose parent type is + * abstract (union or interface), or whose parent type is a concrete object + * type that implements an abstract type (i.e. it appears as a possible type + * of some interface or union in the schema). + * + * Requires the schema so it can resolve field return types as it walks the + * document tree. + */ +export function addTypenames(document: DocumentNode, schema: GraphQLSchema): DocumentNode { + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + + return { + ...document, + definitions: document.definitions.map(def => { + if (def.kind === Kind.OPERATION_DEFINITION) { + const rootType = + def.operation === 'query' + ? queryType + : def.operation === 'mutation' + ? mutationType + : subscriptionType; + + if (!rootType) return def; + + return { + ...def, + selectionSet: walkSelectionSet(def.selectionSet, rootType, false, schema), + }; + } + + if (def.kind === Kind.FRAGMENT_DEFINITION) { + const onType = schema.getType(def.typeCondition.name.value); + if (!onType || !isCompositeType(onType)) return def; + + return { + ...def, + selectionSet: walkSelectionSet(def.selectionSet, onType, isAbstractType(onType), schema), + }; + } + + return def; + }), + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * @param parentIsAbstract - true when the immediately enclosing field (or + * fragment condition) resolved to an abstract type. This is propagated into + * concrete inline-fragment branches so they also receive __typename. + */ +function walkSelectionSet( + selectionSet: SelectionSetNode, + parentType: GraphQLCompositeType, + parentIsAbstract: boolean, + schema: GraphQLSchema, +): SelectionSetNode { + const thisTypeIsAbstract = isAbstractType(parentType); + + // Inject __typename when: + // 1. This selection set is typed as an abstract type directly, OR + // 2. This is a concrete inline-fragment branch inside an abstract parent + // (parentIsAbstract && isObjectType), so the concrete type is + // identifiable only with __typename at runtime. + const needsTypename = + (thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType))) && + !selectionSet.selections.some(s => s.kind === Kind.FIELD && s.name.value === '__typename'); + + const augmentedSelections = selectionSet.selections.map(selection => { + if (selection.kind === Kind.FIELD) { + if (!selection.selectionSet) { + return selection; // Scalar or enum leaf. + } + + const fieldDef = getFieldDef(schema, parentType, selection); + if (!fieldDef) return selection; + + const fieldType = getNamedType(fieldDef.type); + if (!fieldType || !isCompositeType(fieldType)) return selection; + + return { + ...selection, + selectionSet: walkSelectionSet( + selection.selectionSet, + fieldType, + isAbstractType(fieldType), + schema, + ), + } satisfies FieldNode; + } + + if (selection.kind === Kind.INLINE_FRAGMENT) { + // A typed fragment (`... on Foo`) narrows the parent type. + // An untyped fragment inherits the parent type. + const branchType = selection.typeCondition + ? schema.getType(selection.typeCondition.name.value) + : parentType; + + if (!branchType || !isCompositeType(branchType)) return selection; + + return { + ...selection, + selectionSet: walkSelectionSet( + selection.selectionSet, + branchType, + // Pass through whether the enclosing field was abstract, so that + // concrete branches (... on User inside a Node field) still get + // __typename injected into their own selection set. + thisTypeIsAbstract || parentIsAbstract, + schema, + ), + } satisfies InlineFragmentNode; + } + + // FRAGMENT_SPREAD — the definition is handled at the top-level definitions + // pass; the spread node itself carries no selectionSet. + return selection; + }); + + return { + ...selectionSet, + selections: needsTypename ? [...augmentedSelections, TYPENAME_FIELD] : augmentedSelections, + }; +} + +/** + * Resolves the field definition for a given field node on a parent type, + * including the meta-fields __schema, __type, and __typename. + */ +function getFieldDef(schema: GraphQLSchema, parentType: GraphQLCompositeType, field: FieldNode) { + const name = field.name.value; + + if (name === '__schema' && parentType === schema.getQueryType()) { + return SchemaMetaFieldDef; + } + if (name === '__type' && parentType === schema.getQueryType()) { + return TypeMetaFieldDef; + } + if (name === '__typename') { + return TypeNameMetaFieldDef; + } + + return 'getFields' in parentType ? (parentType.getFields()[name] ?? null) : null; +} diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts index f52acf53928..5541491bc49 100644 --- a/packages/libraries/gateway-usage/src/index.ts +++ b/packages/libraries/gateway-usage/src/index.ts @@ -8,6 +8,7 @@ import { type HivePluginOptions, } from '@graphql-hive/core'; import { GatewayPlugin } from '@graphql-hive/gateway-runtime'; +import { addTypenames } from './add-typenames.js'; import { isEntityRequest } from './is-entity-request.js'; import { version } from './version.js'; @@ -88,6 +89,11 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat return; } + // We need __typename on every object in the subgraph result so we can + // resolve abstract types (unions/interfaces) to concrete type coordinates + // when recording field-level metrics downstream. + executionRequest.document = addTypenames(executionRequest.document, subgraphSchema); + const finishSubRequest = collection.subrequest({ subgraph: subgraphName, type: isEntityRequest(executionRequest.document) ? 'ENTITY' : 'ROOT', diff --git a/packages/libraries/gateway-usage/tests/add-typenames.spec.ts b/packages/libraries/gateway-usage/tests/add-typenames.spec.ts new file mode 100644 index 00000000000..cf32a682071 --- /dev/null +++ b/packages/libraries/gateway-usage/tests/add-typenames.spec.ts @@ -0,0 +1,512 @@ +import { buildSchema, parse, print } from 'graphql'; +import { addTypenames } from '../src/add-typenames.js'; + +// --------------------------------------------------------------------------- +// Shared test schema +// --------------------------------------------------------------------------- +// +// Covers every case we test: +// - Concrete object types (User, Post, Address) +// - Interface (Node, Animal) +// - Union (SearchResult) +// - Scalars (ID, String) +// - Nested abstract fields (Animal.offspring: Animal) +// - Mixed concrete/abstract (User.pets: [Animal], User.friends: [Node]) + +const schema = buildSchema(` + interface Node { + id: ID! + } + + interface Animal { + name: String! + offspring: [Animal!]! + } + + type Cat implements Animal & Node { + id: ID! + name: String! + offspring: [Animal!]! + indoor: Boolean! + } + + type Dog implements Animal & Node { + id: ID! + name: String! + offspring: [Animal!]! + breed: String! + } + + union SearchResult = User | Post + + type Address { + city: String! + country: String! + } + + type User implements Node { + id: ID! + name: String! + address: Address! + pets: [Animal!]! + friends: [Node!]! + } + + type Post implements Node { + id: ID! + title: String! + author: User! + } + + type Query { + user(id: ID!): User + node(id: ID!): Node + search(term: String!): [SearchResult!]! + animals: [Animal!]! + health: String! + } + + type Mutation { + createUser(name: String!): User! + } + + type Subscription { + userUpdated: User! + } +`); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function gql(src: string) { + return parse(src); +} + +function typenameCount(doc: ReturnType): number { + return (print(doc).match(/__typename/g) ?? []).length; +} + +// --------------------------------------------------------------------------- +// Concrete object types with no abstract ancestry in the query +// — __typename should NOT be added +// --------------------------------------------------------------------------- + +describe('concrete object types (not inside an abstract field)', () => { + it('does not add __typename when the root field returns a concrete type', () => { + const doc = gql(` + query { + user(id: "1") { + id + name + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); + + it('does not add __typename for nested concrete object fields', () => { + // User → Address: both concrete with no abstract involvement. + const doc = gql(` + query { + user(id: "1") { + address { + city + country + } + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Interface types — __typename SHOULD be added on the abstract field itself +// --------------------------------------------------------------------------- + +describe('interface types', () => { + it('adds __typename when a field returns an interface', () => { + const doc = gql(` + query { + node(id: "1") { + id + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(1); + }); + + it('adds __typename for a list field returning an interface', () => { + const doc = gql(` + query { + animals { + name + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(1); + }); + + it('adds __typename on a nested interface field inside a concrete type', () => { + // user → User (concrete, no __typename); friends → [Node] (interface, add __typename) + const doc = gql(` + query { + user(id: "1") { + friends { + id + } + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(1); + }); + + it('adds __typename on a self-referencing interface field', () => { + // animals → Animal (interface); offspring → Animal (interface) + const doc = gql(` + query { + animals { + name + offspring { + name + } + } + } + `); + // 2: one per abstract selection set + expect(typenameCount(addTypenames(doc, schema))).toBe(2); + }); + + it('does not duplicate __typename when already present on an interface field', () => { + const doc = gql(` + query { + node(id: "1") { + __typename + id + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Union types — __typename SHOULD be added on the abstract field itself +// --------------------------------------------------------------------------- + +describe('union types', () => { + it('adds __typename on the union field selection set', () => { + const doc = gql(` + query { + search(term: "foo") { + ... on User { + name + } + ... on Post { + title + } + } + } + `); + const result = addTypenames(doc, schema); + const printed = print(result); + // search (union) → __typename on the outer set + expect(printed).toContain('__typename'); + }); +}); + +// --------------------------------------------------------------------------- +// Concrete inline-fragment branches of abstract types +// — __typename SHOULD be added inside the branch +// --------------------------------------------------------------------------- + +describe('concrete inline-fragment branches of abstract fields', () => { + it('adds __typename inside a concrete inline fragment on an interface field', () => { + // node → Node (interface): outer set + ... on User branch both get __typename + const doc = gql(` + query { + node(id: "1") { + ... on User { + name + } + } + } + `); + const result = addTypenames(doc, schema); + // 2: one on the node (Node interface) set, one inside the ... on User branch + expect(typenameCount(result)).toBe(2); + }); + + it('adds __typename inside each concrete branch of a union', () => { + const doc = gql(` + query { + search(term: "foo") { + ... on User { + name + } + ... on Post { + title + } + } + } + `); + const result = addTypenames(doc, schema); + // search (union) + ... on User + ... on Post = 3 + expect(typenameCount(result)).toBe(3); + }); + + it('does not duplicate __typename in a concrete branch that already has it', () => { + const doc = gql(` + query { + node(id: "1") { + ... on User { + __typename + name + } + } + } + `); + const result = addTypenames(doc, schema); + // node set + User branch (not duplicated) = 2 + expect(typenameCount(result)).toBe(2); + }); + + it('adds __typename in a concrete branch and recurses into its abstract sub-fields', () => { + // pets → [Animal] (interface); ... on Dog (concrete branch) gets __typename; + // Dog.offspring → [Animal] (interface) also gets __typename. + const doc = gql(` + query { + user(id: "1") { + pets { + ... on Dog { + breed + offspring { + name + } + } + } + } + } + `); + const result = addTypenames(doc, schema); + // pets (Animal interface) + ... on Dog branch + offspring (Animal interface) = 3 + expect(typenameCount(result)).toBe(3); + }); + + it('adds __typename in an untyped inline fragment inside an abstract field', () => { + // An anonymous `... { }` inherits the parent abstract type. + const doc = gql(` + query { + node(id: "1") { + ... { + id + } + } + } + `); + const result = addTypenames(doc, schema); + // node (Node interface) → __typename; anonymous fragment inherits Node → also abstract → __typename + expect(typenameCount(result)).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Mixed concrete + abstract fields on the same type +// --------------------------------------------------------------------------- + +describe('mixed fields', () => { + it('adds __typename only for abstract fields, not for concrete sibling fields', () => { + // user.address → Address (concrete); user.pets → [Animal] (interface) + const doc = gql(` + query { + user(id: "1") { + address { + city + } + pets { + name + } + } + } + `); + const result = addTypenames(doc, schema); + // address is concrete with no abstract parent → no __typename + // pets is abstract → __typename + expect(typenameCount(result)).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Named fragments +// --------------------------------------------------------------------------- + +describe('named fragments', () => { + it('adds __typename in a fragment defined on an interface type', () => { + const doc = gql(` + query { + node(id: "1") { + ...NodeFields + } + } + + fragment NodeFields on Node { + id + } + `); + const result = addTypenames(doc, schema); + // node field (Node) + NodeFields fragment body (also on Node) = 2 + expect(typenameCount(result)).toBe(2); + }); + + it('does not add __typename in a fragment defined on a concrete type not in an abstract context', () => { + const doc = gql(` + query { + user(id: "1") { + ...UserFields + } + } + + fragment UserFields on User { + id + name + } + `); + // user is concrete, UserFields is on User (concrete) → no __typename anywhere + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); + + it('does not add __typename to the fragment spread node itself', () => { + const doc = gql(` + query { + node(id: "1") { + ...NodeFields + } + } + + fragment NodeFields on Node { + id + } + `); + expect(print(addTypenames(doc, schema))).toContain('...NodeFields'); + }); +}); + +// --------------------------------------------------------------------------- +// Introspection fields +// --------------------------------------------------------------------------- + +describe('introspection fields', () => { + it('does not add __typename inside __schema', () => { + const doc = gql(` + query { + __schema { + queryType { + name + } + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); + + it('does not add __typename inside __type', () => { + const doc = gql(` + query { + __type(name: "User") { + fields { + name + } + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Operation types +// --------------------------------------------------------------------------- + +describe('operation types', () => { + it('does not add __typename for a mutation returning a concrete type', () => { + const doc = gql(` + mutation { + createUser(name: "Alice") { + id + name + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); + + it('does not add __typename for a subscription returning a concrete type', () => { + const doc = gql(` + subscription { + userUpdated { + id + name + } + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Scalar-only queries +// --------------------------------------------------------------------------- + +describe('scalar fields', () => { + it('does not add __typename for a scalar-only root field', () => { + const doc = gql(` + query { + health + } + `); + expect(typenameCount(addTypenames(doc, schema))).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('idempotency', () => { + it('is stable across multiple applications on an interface field', () => { + const doc = gql(` + query { + node(id: "1") { + ... on User { + name + } + } + } + `); + const once = addTypenames(doc, schema); + const twice = addTypenames(once, schema); + expect(print(once)).toBe(print(twice)); + }); +}); + +// --------------------------------------------------------------------------- +// Immutability +// --------------------------------------------------------------------------- + +describe('immutability', () => { + it('does not mutate the original document', () => { + const doc = gql(` + query { + node(id: "1") { + ... on User { + name + } + } + } + `); + const before = print(doc); + addTypenames(doc, schema); + expect(print(doc)).toBe(before); + }); +}); From 58aca019deaf57281692cc3ac71025ff02e603c7 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:07:02 -0700 Subject: [PATCH 36/62] Cache modified operations in gateway-usage --- packages/libraries/gateway-usage/package.json | 3 +- .../gateway-usage/src/add-typenames.ts | 131 ++++++++++++------ packages/libraries/gateway-usage/src/index.ts | 39 +++++- 3 files changed, 123 insertions(+), 50 deletions(-) diff --git a/packages/libraries/gateway-usage/package.json b/packages/libraries/gateway-usage/package.json index fc0cff1f5b9..dc29c4a4cd6 100644 --- a/packages/libraries/gateway-usage/package.json +++ b/packages/libraries/gateway-usage/package.json @@ -48,7 +48,8 @@ "graphql": "^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { - "@graphql-hive/core": "workspace:*" + "@graphql-hive/core": "workspace:*", + "tiny-lru": "11.4.7" }, "devDependencies": { "@envelop/types": "^5.0.0" diff --git a/packages/libraries/gateway-usage/src/add-typenames.ts b/packages/libraries/gateway-usage/src/add-typenames.ts index 9a4664bf701..56ef1f3cde2 100644 --- a/packages/libraries/gateway-usage/src/add-typenames.ts +++ b/packages/libraries/gateway-usage/src/add-typenames.ts @@ -34,37 +34,60 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); - return { - ...document, - definitions: document.definitions.map(def => { - if (def.kind === Kind.OPERATION_DEFINITION) { - const rootType = - def.operation === 'query' - ? queryType - : def.operation === 'mutation' - ? mutationType - : subscriptionType; - - if (!rootType) return def; - + let definitionsChanged = false; + + const augmentedDefinitions = document.definitions.map(def => { + if (def.kind === Kind.OPERATION_DEFINITION) { + const rootType = + def.operation === 'query' + ? queryType + : def.operation === 'mutation' + ? mutationType + : subscriptionType; + + if (!rootType) return def; + + const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); + if (newSelectionSet !== def.selectionSet) { + definitionsChanged = true; return { ...def, - selectionSet: walkSelectionSet(def.selectionSet, rootType, false, schema), + selectionSet: newSelectionSet, }; } + return def; + } - if (def.kind === Kind.FRAGMENT_DEFINITION) { - const onType = schema.getType(def.typeCondition.name.value); - if (!onType || !isCompositeType(onType)) return def; - + if (def.kind === Kind.FRAGMENT_DEFINITION) { + const onType = schema.getType(def.typeCondition.name.value); + if (!onType || !isCompositeType(onType)) return def; + + const newSelectionSet = walkSelectionSet( + def.selectionSet, + onType, + isAbstractType(onType), + schema, + ); + if (newSelectionSet !== def.selectionSet) { + definitionsChanged = true; return { ...def, - selectionSet: walkSelectionSet(def.selectionSet, onType, isAbstractType(onType), schema), + selectionSet: newSelectionSet, }; } - return def; - }), + } + + return def; + }); + + if (!definitionsChanged) { + return document; + } + + return { + ...document, + definitions: augmentedDefinitions, }; } @@ -74,8 +97,8 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc /** * @param parentIsAbstract - true when the immediately enclosing field (or - * fragment condition) resolved to an abstract type. This is propagated into - * concrete inline-fragment branches so they also receive __typename. + * fragment condition) resolved to an abstract type. This is propagated into + * concrete inline-fragment branches so they also receive __typename. */ function walkSelectionSet( selectionSet: SelectionSetNode, @@ -94,6 +117,8 @@ function walkSelectionSet( (thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType))) && !selectionSet.selections.some(s => s.kind === Kind.FIELD && s.name.value === '__typename'); + let selectionsChanged = false; + const augmentedSelections = selectionSet.selections.map(selection => { if (selection.kind === Kind.FIELD) { if (!selection.selectionSet) { @@ -106,15 +131,22 @@ function walkSelectionSet( const fieldType = getNamedType(fieldDef.type); if (!fieldType || !isCompositeType(fieldType)) return selection; - return { - ...selection, - selectionSet: walkSelectionSet( - selection.selectionSet, - fieldType, - isAbstractType(fieldType), - schema, - ), - } satisfies FieldNode; + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + fieldType, + isAbstractType(fieldType), + schema, + ); + + if (newSelectionSet !== selection.selectionSet) { + selectionsChanged = true; + return { + ...selection, + selectionSet: newSelectionSet, + } satisfies FieldNode; + } + + return selection; } if (selection.kind === Kind.INLINE_FRAGMENT) { @@ -126,18 +158,25 @@ function walkSelectionSet( if (!branchType || !isCompositeType(branchType)) return selection; - return { - ...selection, - selectionSet: walkSelectionSet( - selection.selectionSet, - branchType, - // Pass through whether the enclosing field was abstract, so that - // concrete branches (... on User inside a Node field) still get - // __typename injected into their own selection set. - thisTypeIsAbstract || parentIsAbstract, - schema, - ), - } satisfies InlineFragmentNode; + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + branchType, + // Pass through whether the enclosing field was abstract, so that + // concrete branches (... on User inside a Node field) still get + // __typename injected into their own selection set. + thisTypeIsAbstract || parentIsAbstract, + schema, + ); + + if (newSelectionSet !== selection.selectionSet) { + selectionsChanged = true; + return { + ...selection, + selectionSet: newSelectionSet, + } satisfies InlineFragmentNode; + } + + return selection; } // FRAGMENT_SPREAD — the definition is handled at the top-level definitions @@ -145,6 +184,10 @@ function walkSelectionSet( return selection; }); + if (!needsTypename && !selectionsChanged) { + return selectionSet; + } + return { ...selectionSet, selections: needsTypename ? [...augmentedSelections, TYPENAME_FIELD] : augmentedSelections, diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-usage/src/index.ts index 5541491bc49..ab422eed774 100644 --- a/packages/libraries/gateway-usage/src/index.ts +++ b/packages/libraries/gateway-usage/src/index.ts @@ -1,4 +1,5 @@ -import { responsePathAsArray, type GraphQLError } from 'graphql'; +import { DocumentNode, print, responsePathAsArray, type GraphQLError } from 'graphql'; +import { lru } from 'tiny-lru'; import { autoDisposeSymbol, createHive as createHiveClient, @@ -26,6 +27,11 @@ export function createHive(clientOrOptions: HivePluginOptions) { export type GatewayPluginOptions = HivePluginOptions & { /** Opt in to sending subgraph metrics. This feature is */ fieldLevelMetricsEnabled?: boolean; + /** + * Size of document cache. This is used to store a transformed version of the operation + * because abstract types must include a __typename. Default: 10_000 + */ + cache?: number; }; export function useHive(clientOrOptions: HiveClient): GatewayPlugin; @@ -42,7 +48,12 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat }); void hive.info(); + /** stores the resulting status from fetches */ const statusMap = new WeakMap(); + /** stores the original query SDL to avoid having to print */ + const operationCache = lru( + isHiveClient(clientOrOptions) ? 10_000 : (clientOrOptions.cache ?? 10_000), + ); if (hive[autoDisposeSymbol]) { if (global.process) { const signals = Array.isArray(hive[autoDisposeSymbol]) @@ -89,10 +100,11 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat return; } - // We need __typename on every object in the subgraph result so we can - // resolve abstract types (unions/interfaces) to concrete type coordinates - // when recording field-level metrics downstream. - executionRequest.document = addTypenames(executionRequest.document, subgraphSchema); + /** + * Note that we need __typename on every abstract type in the subgraph call. + * This is added in the "onExecute" hook to the entire document. So subgraph + * calls should also include this field. + */ const finishSubRequest = collection.subrequest({ subgraph: subgraphName, @@ -126,6 +138,23 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat (args.contextValue as any).__hiveUsageCollection = collection; } + // We need __typename on every object in the subgraph result so we can + // resolve abstract types (unions/interfaces) to concrete type coordinates + // when recording field-level metrics downstream. + // This is done here for more performant caching of the result. + const query = args.contextValue.params.query ?? print(args.document); + const cachedDocument = operationCache.get(query); + if (cachedDocument) { + // If "true" is cached, then this operation doesn't need stored because it's identical to the original. + // Else, the document hash been modified and cached + if (cachedDocument !== true) { + args.document = cachedDocument; + } + } else { + const modifiedDocument = addTypenames(args.document, args.schema); + operationCache.set(query, args.document === modifiedDocument || args.document); + } + return { onExecuteDone({ result }) { if (!isAsyncIterable(result)) { From 720fab476fd49e372e0fafbf6fb70719378106e9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:45:07 -0700 Subject: [PATCH 37/62] Separate requests from resolutions resolvers; add separate resolution stats to coordinate insights --- .../src/modules/operations/module.graphql.ts | 33 +++- .../providers/operations-manager.ts | 46 ++++- .../operations/providers/operations-reader.ts | 177 +++++++++++++++--- .../resolvers/SchemaCoordinateStats.ts | 31 ++- .../modules/schema/module.graphql.mappers.ts | 3 + .../api/src/modules/schema/module.graphql.ts | 5 + .../services/api/src/modules/schema/utils.ts | 4 + .../src/components/target/explorer/common.tsx | 13 +- .../src/pages/target-insights-coordinate.tsx | 139 ++++++++++++-- 9 files changed, 393 insertions(+), 58 deletions(-) diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 87ec13e6b96..5967aac971a 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -158,15 +158,46 @@ export default gql` } type SchemaCoordinateStats { - requestsOverTime(resolution: Int!): [RequestsOverTime!]! + requestsOverTime( + """ + How fine of granularity to show + """ + resolution: Int! + ): [RequestsOverTime!]! + + """ + The number of times a coordinate has been resolved over time. Resolutions + differ from requests by taking the actual execution of an operation into account. + """ + resolutionsOverTime( + """ + How fine of granularity to show + """ + resolution: Int! + ): [RequestsOverTime!]! """ How many times this coordinate has reported an error. This is available only if subgraph visibility is enabled for your organization and the gateway is sending field level metrics. """ failuresOverTime(resolution: Int!): [FailuresOverTime!] + + """ + How many requests included this coordinate. If a coordinate is used by an operation, + it counts as one, regardless of how many times the coordinate is used. + """ totalRequests: SafeInt! @tag(name: "public") + + """ + How many times a coordinate has been executed. + """ + totalResolutions: SafeInt + + """ + How many errors have been returned for a coordinate. + """ totalFailures: SafeInt + operations: OperationStatsValuesConnection! @tag(name: "public") clients: ClientStatsValuesConnection! @tag(name: "public") } diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index a267debb530..8af934702ce 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -212,7 +212,7 @@ export class OperationsManager { }); } - async countRequestsWithSchemaCoordinate({ + async countResolutionsAtSchemaCoordinate({ organizationId, projectId, targetId, @@ -223,7 +223,7 @@ export class OperationsManager { schemaCoordinate: string; } & Listify) { this.logger.info( - 'Counting requests with schema coordinate (period=%o, target=%s, coordinate=%s)', + 'Counting resolutions with schema coordinate (period=%o, target=%s, coordinate=%s)', period, targetId, schemaCoordinate, @@ -241,12 +241,49 @@ export class OperationsManager { organizationId, }); + if (org.featureFlags.subgraphVisibility !== true) { + return null; + } + + return this.reader + .countCoordinateResolutions({ + targetIds: Array.isArray(targetId) ? targetId : [targetId], + period, + schemaCoordinate, + }) + .then(r => r[schemaCoordinate]); + } + + async countRequestsWithSchemaCoordinate({ + organizationId, + projectId, + targetId, + period, + schemaCoordinate, + }: { + period: DateRange; + schemaCoordinate: string; + } & Listify) { + this.logger.info( + 'Counting requests with schema coordinate (period=%o, target=%s, coordinate=%s)', + period, + targetId, + schemaCoordinate, + ); + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId, + params: { + organizationId, + projectId, + }, + }); + return this.reader .countCoordinate({ targetIds: Array.isArray(targetId) ? targetId : [targetId], period, schemaCoordinate, - aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', }) .then(r => r[schemaCoordinate]); } @@ -1263,7 +1300,6 @@ export class OperationsManager { target, period, typename, - aggregateSource: org.featureFlags.subgraphVisibility ? 'coordinate_counts' : 'coordinates', }), org.featureFlags.subgraphVisibility ? this.reader.countErrorCoordinatesOfType({ @@ -1278,6 +1314,7 @@ export class OperationsManager { [coordinate: string]: { total: number; isUsed: boolean; + totalResolutions?: number | null; errorTotal?: number; }; } = {}; @@ -1285,6 +1322,7 @@ export class OperationsManager { for (const row of rows) { records[row.coordinate] = { total: row.total, + totalResolutions: row.totalResolutions, isUsed: row.total > 0, }; } diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index d7e1554b2d2..a3fb9b5074b 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -199,6 +199,114 @@ export class OperationsReader { }).then(r => r[this.makeId({ type, field, argument })]); } + countCoordinateResolutions = batchBy< + { + schemaCoordinate: string; + targetIds: readonly string[]; + period: DateRange; + operations?: readonly string[]; + excludedClients?: readonly string[] | null; + }, + Record + >( + item => + `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}`, + async items => { + const schemaCoordinates = items.map(item => item.schemaCoordinate); + return await this.countCoordinateResolution({ + targetIds: items[0].targetIds, + excludedClients: items[0].excludedClients, + period: items[0].period, + operations: items[0].operations, + schemaCoordinates, + }).then(result => + items.map(item => + Promise.resolve({ [item.schemaCoordinate]: result[item.schemaCoordinate] }), + ), + ); + }, + ); + + /** + * Count the number of resolutions for a coordinate. + */ + public async countCoordinateResolution({ + schemaCoordinates, + targetIds, + period, + operations, + excludedClients, + }: { + schemaCoordinates: readonly string[]; + targetIds: string | readonly string[]; + period: DateRange; + operations?: readonly string[]; + excludedClients?: readonly string[] | null; + }) { + const conditions = [sql`(coordinate IN (${sql.array(schemaCoordinates, 'String')}))`]; + + if (Array.isArray(excludedClients) && excludedClients.length > 0) { + // Eliminate coordinates fetched by excluded clients. + // We can connect a coordinate to a client by using the hash column. + // The hash column is basically a unique identifier of a GraphQL operation. + // In the following query we fetch all hashes that were used only by the excluded clients. + conditions.push(sql` + hash NOT IN ( + SELECT hash FROM ( + SELECT + hash, + countIf(client_name NOT IN (${sql.array( + excludedClients, + 'String', + )})) as non_excluded_clients_total + FROM clients_daily ${this.createFilter({ + target: targetIds, + period, + })} + GROUP BY hash + ) WHERE non_excluded_clients_total = 0 + ) + `); + } + + const query = this.pickAggregationByPeriod({ + period, + query: aggregationTableName => sql` + SELECT + coordinate, + sum(total) as total + FROM ${aggregationTableName('coordinate_counts')} + ${this.createFilter({ + target: targetIds, + period, + operations, + extra: conditions, + })} + GROUP BY coordinate + `, + queryId: aggregation => `count_fields_v2_${aggregation}`, + timeout: 30_000, + }); + + const res = await this.clickHouse.query<{ + total: string; + coordinate: string; + }>(query); + + const stats: Record = {}; + for (const row of res.data) { + stats[row.coordinate] = ensureNumber(row.total); + } + + for (const coordinate of schemaCoordinates) { + if (typeof stats[coordinate] !== 'number') { + stats[coordinate] = 0; + } + } + + return stats; + } + countCoordinate = batchBy< { schemaCoordinate: string; @@ -206,12 +314,11 @@ export class OperationsReader { period: DateRange; operations?: readonly string[]; excludedClients?: readonly string[] | null; - aggregateSource?: 'coordinate_counts' | 'coordinates'; }, Record >( item => - `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}-${item.aggregateSource}`, + `${item.targetIds.join(',')}-${item.excludedClients?.join(',') ?? ''}-${item.operations?.join(',') ?? ''}-${item.period.from.toISOString()}-${item.period.to.toISOString()}`, async items => { const schemaCoordinates = items.map(item => item.schemaCoordinate); return await this.countCoordinates({ @@ -220,7 +327,6 @@ export class OperationsReader { period: items[0].period, operations: items[0].operations, schemaCoordinates, - aggregateSource: items[0].aggregateSource, }).then(result => items.map(item => Promise.resolve({ [item.schemaCoordinate]: result[item.schemaCoordinate] }), @@ -230,9 +336,7 @@ export class OperationsReader { ); /** - * Can count the number of requests hitting a coordinate or the number of resolutions depending on the aggregateSource. - * By default, this will return the number of requests. Pass the aggregate source "coordinate_counts" to get the total - * count of resolutions for a coordinate. + * Can count the number of requests hitting a coordinate. */ public async countCoordinates({ schemaCoordinates, @@ -240,14 +344,12 @@ export class OperationsReader { period, operations, excludedClients, - aggregateSource = 'coordinates', }: { schemaCoordinates: readonly string[]; targetIds: string | readonly string[]; period: DateRange; operations?: readonly string[]; excludedClients?: readonly string[] | null; - aggregateSource?: 'coordinate_counts' | 'coordinates'; }) { const conditions = [sql`(coordinate IN (${sql.array(schemaCoordinates, 'String')}))`]; @@ -281,7 +383,7 @@ export class OperationsReader { SELECT coordinate, sum(total) as total - FROM ${aggregationTableName(aggregateSource)} + FROM ${aggregationTableName('coordinates')} ${this.createFilter({ target: targetIds, period, @@ -2383,7 +2485,6 @@ export class OperationsReader { target: string; period: DateRange; typename: string; - aggregateSource?: 'coordinate_counts' | 'coordinates'; }>, ) => { const aggregationMap = new Map< @@ -2392,7 +2493,6 @@ export class OperationsReader { target: string; period: DateRange; typenames: string[]; - aggregateSource?: 'coordinate_counts' | 'coordinates'; } >(); @@ -2413,7 +2513,6 @@ export class OperationsReader { target: selector.target, period: selector.period, typenames: [selector.typename], - aggregateSource: selector.aggregateSource, }); } else { value.typenames.push(selector.typename); @@ -2426,6 +2525,7 @@ export class OperationsReader { { coordinate: string; total: number; + totalResolutions: number | null; }[] > >(); @@ -2440,7 +2540,6 @@ export class OperationsReader { target: selector.target, period: selector.period, typenames: selector.typenames, - aggregateSource: selector.aggregateSource, }), ); } @@ -2464,38 +2563,60 @@ export class OperationsReader { target, period, typenames, - aggregateSource = 'coordinates', }: { target: string; period: DateRange; typenames: string[]; - aggregateSource?: 'coordinate_counts' | 'coordinates'; }) { const typesConditions = typenames.map( t => sql`coordinate = ${t} OR coordinate LIKE ${t + '.%'}`, ); - const result = await this.clickHouse.query<{ - coordinate: string; - total: number; - }>( - this.pickAggregationByPeriod({ - query: aggregationTableName => sql` - SELECT coordinate, sum(total) as total FROM ${aggregationTableName(aggregateSource)} + + const query = this.pickAggregationByPeriod({ + query: aggregationTableName => sql` + SELECT + coalesce(c.coordinate, r.coordinate) AS coordinate, + c.total AS total, + r.totalResolutions AS totalResolutions + FROM + ( + SELECT coordinate, sum(total) as total + FROM ${aggregationTableName('coordinates')} ${this.createFilter({ target, period, extra: [sql`(${sql.join(typesConditions, ' OR ')})`], })} - GROUP BY coordinate`, - queryId: aggregation => `coordinates_per_types_${aggregation}`, - timeout: 15_000, - period, - }), - ); + GROUP BY coordinate + ) AS c + LEFT OUTER JOIN + ( + SELECT coordinate, sum(total) as totalResolutions + FROM ${aggregationTableName('coordinate_counts')} + ${this.createFilter({ + target, + period, + extra: [sql`(${sql.join(typesConditions, ' OR ')})`], + })} + GROUP BY coordinate + ) AS r + ON c.coordinate = r.coordinate + ;`, + queryId: aggregation => `coordinates_per_types_${aggregation}`, + timeout: 15_000, + period, + }); + + const result = await this.clickHouse.query<{ + coordinate: string; + total: number; + totalResolutions?: number | null; + }>(query); return result.data.map(row => ({ coordinate: row.coordinate, total: ensureNumber(row.total), + totalResolutions: row.totalResolutions ? ensureNumber(row.totalResolutions) : null, })); } diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index eb011a3e549..168f5316089 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -8,8 +8,10 @@ export const SchemaCoordinateStats: Pick< | 'failuresOverTime' | 'operations' | 'requestsOverTime' + | 'resolutionsOverTime' | 'totalFailures' | 'totalRequests' + | 'totalResolutions' > = { totalRequests: ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { return injector.get(OperationsManager).countRequestsWithSchemaCoordinate({ @@ -33,7 +35,7 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, - requestsOverTime: ( + resolutionsOverTime: ( { organization, project, target, period, schemaCoordinate }, { resolution }, { injector }, @@ -47,6 +49,20 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, + requestsOverTime: ( + { organization, project, target, period, schemaCoordinate }, + { resolution }, + { injector }, + ) => { + return injector.get(OperationsManager).readRequestsOverTime({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + schemaCoordinate, + }); + }, operations: async ( { organization, project, target, period, schemaCoordinate }, args, @@ -133,4 +149,17 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, + totalResolutions: ( + { organization, project, target, period, schemaCoordinate }, + _, + { injector }, + ) => { + return injector.get(OperationsManager).countResolutionsAtSchemaCoordinate({ + organizationId: organization, + projectId: project, + targetId: target, + period, + schemaCoordinate, + }); + }, }; diff --git a/packages/services/api/src/modules/schema/module.graphql.mappers.ts b/packages/services/api/src/modules/schema/module.graphql.mappers.ts index 4cf2a6e06d6..5a6d44c6354 100644 --- a/packages/services/api/src/modules/schema/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/schema/module.graphql.mappers.ts @@ -105,6 +105,7 @@ export type WithSchemaCoordinatesUsage = T & { | PromiseOrValue<{ [coordinate: string]: { total: number; + totalResolutions?: number | null; errorTotal?: number | null; usedByClients: () => PromiseOrValue>; period: DateRange; @@ -253,6 +254,7 @@ export type SchemaCoordinateUsageMapper = | { isUsed: true; total: number; + totalResolutions?: number | null; errorTotal?: number | null; usedByClients: () => PromiseOrValue>; period: DateRange; @@ -264,6 +266,7 @@ export type SchemaCoordinateUsageMapper = | { isUsed: false; total: number; + totalResolutions?: number | null; errorTotal?: number | null; usedByClients: () => Array; }; diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 99140559dad..78ec345cbf6 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -1141,6 +1141,11 @@ export default gql` """ total: Float! + """ + The total amount of resolutions of the schema coordinate within the contextual period. + """ + totalResolutions: Float + """ The total amount of errors of the schema coordinate within the contextual period. """ diff --git a/packages/services/api/src/modules/schema/utils.ts b/packages/services/api/src/modules/schema/utils.ts index bb65558d96d..023dbe21cea 100644 --- a/packages/services/api/src/modules/schema/utils.ts +++ b/packages/services/api/src/modules/schema/utils.ts @@ -370,6 +370,7 @@ export function usage( return { // TODO: This is a hack to mark the field as used but without passing exact number as we don't need the exact number in "Unused schema view". total: 1, + totalResolutions: 1, errorTotal: null, isUsed: true, usedByClients: () => [], @@ -383,6 +384,7 @@ export function usage( return { total: 0, + totalResolutions: 0, errorTotal: null, isUsed: false, usedByClients: () => [], @@ -395,6 +397,7 @@ export function usage( return coordinateUsage && coordinateUsage.total > 0 ? { total: coordinateUsage.total, + totalResolutions: coordinateUsage.totalResolutions, errorTotal: coordinateUsage.errorTotal, isUsed: true, usedByClients: coordinateUsage.usedByClients, @@ -406,6 +409,7 @@ export function usage( } : { total: 0, + totalResolutions: 0, errorTotal: null, isUsed: false, usedByClients: () => [], diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 43283c093fc..69ccfabfc36 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -30,6 +30,7 @@ export function Description(props: { description: string }) { const SchemaExplorerUsageStats_UsageFragment = graphql(` fragment SchemaExplorerUsageStats_UsageFragment on SchemaCoordinateUsage { total + totalResolutions errorTotal isUsed usedByClients @@ -51,9 +52,13 @@ export function SchemaExplorerUsageStats(props: { }) { const usage = useFragment(SchemaExplorerUsageStats_UsageFragment, props.usage); const percentage = props.totalRequests ? (usage.total / props.totalRequests) * 100 : 0; - const availability = usage.errorTotal - ? ((1.0 - usage.errorTotal / usage.total) * 100.0).toFixed(2) - : null; + const availability = + usage.errorTotal || usage.totalResolutions + ? ( + (1.0 - (usage.errorTotal ?? 0) / Math.max(usage.totalResolutions ?? 1, 1)) * + 100.0 + ).toFixed(2) + : null; const kindLabel = useMemo(() => props.kindLabel ?? 'field', [props.kindLabel]); @@ -63,7 +68,7 @@ export function SchemaExplorerUsageStats(props: {
{formatNumber(usage.total)} {availability ? ({availability}%) : null} diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index 898d491ed1e..624d0ce98fc 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -25,7 +25,7 @@ import { QueryError } from '@/components/ui/query-error'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { useChartStyles } from '@/lib/utils'; +import { cn, useChartStyles } from '@/lib/utils'; import { Link } from '@tanstack/react-router'; const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` @@ -48,7 +48,12 @@ const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` date value } + resolutionsOverTime(resolution: $resolution) { + date + value + } totalRequests + totalResolutions failuresOverTime(resolution: $resolution) { date value @@ -131,6 +136,15 @@ function SchemaCoordinateView(props: { return points.map(node => [node.date, node.value]); }, [points]); + const resolutionPoints = query.data?.target?.schemaCoordinateStats?.resolutionsOverTime; + const resolutionsOverTime = useMemo(() => { + if (!resolutionPoints) { + return []; + } + + return resolutionPoints.map(node => [node.date, node.value]); + }, [resolutionPoints]); + const errorPoints = query.data?.target?.schemaCoordinateStats?.failuresOverTime; const errorsOverTime = useMemo(() => { if (!errorPoints) { @@ -140,6 +154,7 @@ function SchemaCoordinateView(props: { return errorPoints.map(node => [node.date, node.value]); }, [errorPoints]); const totalRequests = query.data?.target?.schemaCoordinateStats?.totalRequests ?? 0; + const totalResolutions = query.data?.target?.schemaCoordinateStats?.totalResolutions ?? 0; const totalFailures = query.data?.target?.schemaCoordinateStats.totalFailures ?? null; const totalOperations = query.data?.target?.schemaCoordinateStats?.operations.edges.length ?? 0; const totalClients = query.data?.target?.schemaCoordinateStats?.clients.edges.length ?? 0; @@ -209,32 +224,45 @@ function SchemaCoordinateView(props: {
- - - {hasFieldLevelMetrics ? 'Total resolutions' : 'Total calls'} - + + Total calls
{isLoading ? '-' : formatNumber(totalRequests)} - {totalFailures ? ( - - ({formatNumber(totalFailures)} errors) - - ) : null}

- {hasFieldLevelMetrics ? 'Resolved' : 'Requests'} in{' '} - {dateRangeController.selectedPreset.label.toLowerCase()} + Requests in {dateRangeController.selectedPreset.label.toLowerCase()}

+ {hasFieldLevelMetrics ? ( + + + Total resolutions + + + +
+ {isLoading ? '-' : formatNumber(totalResolutions)} + {totalFailures ? ( + + ({formatNumber(totalFailures)} errors) + + ) : null} +
+

+ Resolved in {dateRangeController.selectedPreset.label.toLowerCase()} +

+
+
+ ) : null} Requests per minute @@ -288,9 +316,7 @@ This differs from Request Count because a single request can resolve a field mul Activity - {hasFieldLevelMetrics - ? `Number of times the coordinate ${props.coordinate} has resolved over time` - : `GraphQL requests with ${props.coordinate} over time`} + GraphQL requests with {props.coordinate} over time @@ -348,6 +374,79 @@ This differs from Request Count because a single request can resolve a field mul large: true, data: requestsOverTime, }, + ], + }} + /> + )} + + + + + Number of times the coordinate {props.coordinate} has resolved over time + + + + + {size => ( + formatNumber(value), + }, + }, + ], + series: [ + resolutionsOverTime?.length + ? { + type: 'line', + name: 'Resolutions', + showSymbol: false, + smooth: false, + color: colors.primary, + areaStyle: {}, + emphasis: { + focus: 'series', + }, + large: true, + data: resolutionsOverTime, + } + : undefined, errorsOverTime?.length ? { type: 'line', From ef86ec8d86454971f4a14e8d192f0ed783e762b8 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:41:05 -0700 Subject: [PATCH 38/62] Add a bar to represent availability and a tooltip --- .../target/explorer/availability-bar.tsx | 43 +++++++++++ .../src/components/target/explorer/common.tsx | 71 +++++++++++++++---- 2 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 packages/web/app/src/components/target/explorer/availability-bar.tsx diff --git a/packages/web/app/src/components/target/explorer/availability-bar.tsx b/packages/web/app/src/components/target/explorer/availability-bar.tsx new file mode 100644 index 00000000000..44c1acb2e02 --- /dev/null +++ b/packages/web/app/src/components/target/explorer/availability-bar.tsx @@ -0,0 +1,43 @@ +import { TriangleAlertIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export default function AvailabilityBar({ + availability, + className, +}: { + availability: number; + /** Use to set the width and height of the component*/ + className?: string; +}) { + // Ensure the percentage stays strictly between 0 and 100 + const safePercent = Math.min(Math.max(availability, 0), 100); + // Scale the width: 50% availability -> 0% visual width, 100% availability -> 100% visual width + const scaledPercent = Math.max((safePercent - 50) * 2, 0); + // If it's less than 100%, cap the width so at least 1px of red is always visible + const fillWidth = scaledPercent < 100 ? `calc(min(${scaledPercent}%, 100% - 1px))` : '100%'; + const showWarning = safePercent < 50; + + return ( +
+
+ {showWarning && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 69ccfabfc36..8bdc7027e8c 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -9,6 +9,7 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { formatNumber, toDecimal } from '@/lib/hooks'; import { capitalize, cn } from '@/lib/utils'; import { Link as NextLink, useRouter } from '@tanstack/react-router'; +import AvailabilityBar from './availability-bar'; import { useDescriptionsVisibleToggle } from './provider'; import { SupergraphMetadataList } from './super-graph-metadata'; import { useExplorerFieldFiltering } from './utils'; @@ -52,13 +53,10 @@ export function SchemaExplorerUsageStats(props: { }) { const usage = useFragment(SchemaExplorerUsageStats_UsageFragment, props.usage); const percentage = props.totalRequests ? (usage.total / props.totalRequests) * 100 : 0; - const availability = - usage.errorTotal || usage.totalResolutions - ? ( - (1.0 - (usage.errorTotal ?? 0) / Math.max(usage.totalResolutions ?? 1, 1)) * - 100.0 - ).toFixed(2) - : null; + const hasFieldLevelMetrics = !!(usage.errorTotal != null || usage.totalResolutions); + const availability = hasFieldLevelMetrics + ? (1.0 - (usage.errorTotal ?? 0) / Math.max(usage.totalResolutions ?? 1, 1)) * 100.0 + : null; const kindLabel = useMemo(() => props.kindLabel ?? 'field', [props.kindLabel]); @@ -66,12 +64,57 @@ export function SchemaExplorerUsageStats(props: {
-
+
{formatNumber(usage.total)} - {availability ? ({availability}%) : null} + {availability ? ( + + + + + +
+
{capitalize(kindLabel)} Stats
+ {hasFieldLevelMetrics ? ( +
+ Requests counts how many client requests + asked for a field, whereas Resolutions{' '} + counts the actual number of times your backend executed code to fetch that + field's data (which can multiply within lists or drop to zero if a parent + returned null). +
+ ) : null} + + + + + + {usage.totalResolutions ? ( + + + + ) : null} + {usage.errorTotal ? ( + + + + ) : null} + + + + +
+ {usage.total} Requests{' '} + {hasFieldLevelMetrics ? 'with' : null} +
{usage.totalResolutions} Resolutions
{usage.errorTotal} Errors
+ for{' '} + + {availability.toFixed(2)}% Availability + +
+
+
+
+ ) : null}
- +
{capitalize(kindLabel)} Usage
{usage.isUsed === false ? ( @@ -148,7 +191,7 @@ export function SchemaExplorerUsageStats(props: { - + <>
Client Usage
From be42cc1fa394cd4af60169eeb88c3eb7aa015c4e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:11:00 -0700 Subject: [PATCH 39/62] Add a list of error codes to coordinate insights --- .../src/modules/operations/module.graphql.ts | 23 +++++++++++ .../providers/operations-manager.ts | 38 ++++++++++++++++++ .../operations/providers/operations-reader.ts | 40 +++++++++++++++++++ .../resolvers/SchemaCoordinateStats.ts | 19 +++++++++ .../src/pages/target-insights-coordinate.tsx | 37 +++++++++++++++++ 5 files changed, 157 insertions(+) diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 5967aac971a..e69d76f13de 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -200,6 +200,7 @@ export default gql` operations: OperationStatsValuesConnection! @tag(name: "public") clients: ClientStatsValuesConnection! @tag(name: "public") + errors: ErrorStatsValuesConnection } type OperationsStats { @@ -234,6 +235,28 @@ export default gql` pageInfo: PageInfo! @tag(name: "public") } + type ErrorStatsValues { + """ + An error identification code returned at 'extensions.code' in a graphql errors object. + """ + code: String! + + """ + Total number of errors returned with this code. + """ + count: SafeInt! + } + + type ErrorStatsValuesEdge { + node: ErrorStatsValues! + cursor: String! + } + + type ErrorStatsValuesConnection { + edges: [ErrorStatsValuesEdge!]! + pageInfo: PageInfo! + } + type MonthlyUsage { total: SafeInt! """ diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 8af934702ce..1ab382cde2b 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -1408,4 +1408,42 @@ export class OperationsManager { return records; } + + @cache< + { + period: DateRange; + } & TargetSelector + >(selector => JSON.stringify(selector)) + @traceFn('OperationManager.errorCodesAtSchemaCoordinate', { + initAttributes: input => ({ + 'hive.organization.id': input.organizationId, + 'hive.project.id': input.projectId, + 'hive.target.id': input.targetId, + }), + }) + async errorCodesAtSchemaCoordinate({ + period, + targetId: target, + projectId: project, + organizationId: organization, + schemaCoordinate, + }: { + period: DateRange; + schemaCoordinate: string; + } & TargetSelector) { + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: organization, + params: { + organizationId: organization, + projectId: project, + }, + }); + + return this.reader.errorCodesAtSchemaCoordinate({ + period, + target, + schemaCoordinate, + }); + } } diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index a3fb9b5074b..f87fcea694c 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -2866,6 +2866,46 @@ export class OperationsReader { })); } + async errorCodesAtSchemaCoordinate({ + period, + target, + schemaCoordinate, + }: { + period: { + from: Date; + to: Date; + }; + target: string; + schemaCoordinate: string; + }) { + const result = await this.clickHouse.query<{ + count: number; + code: string; + }>( + this.pickAggregationByPeriod({ + query: aggregationTableName => sql` + SELECT + sum(total_errors) as count, + code + FROM ${aggregationTableName('coordinate_errors')} + PREWHERE + timestamp >= toDateTime(${formatDate(period.from)}, 'UTC') + AND + timestamp <= toDateTime(${formatDate(period.to)}, 'UTC') + AND + target = ${target} + AND + coordinate = ${schemaCoordinate} + GROUP BY code`, + queryId: aggregation => `error_codes_at_schema_coordinate_${aggregation}`, + timeout: 15_000, + period, + }), + ); + // @TODO safe parse + return result.data; + } + public createFilter({ target, period, diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index 168f5316089..522410bcc17 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -5,6 +5,7 @@ import type { SchemaCoordinateStatsResolvers } from './../../../__generated__/ty export const SchemaCoordinateStats: Pick< SchemaCoordinateStatsResolvers, | 'clients' + | 'errors' | 'failuresOverTime' | 'operations' | 'requestsOverTime' @@ -162,4 +163,22 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, + errors: async ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { + const nodes = await injector.get(OperationsManager).errorCodesAtSchemaCoordinate({ + organizationId: organization, + projectId: project, + targetId: target, + period, + schemaCoordinate, + }); + return { + edges: nodes.map(node => ({ node, cursor: '' })), + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: '', + startCursor: '', + }, + }; + }, }; diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index 624d0ce98fc..ff8037abb92 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -77,6 +77,14 @@ const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` } } } + errors { + edges { + node { + code + count + } + } + } } latestValidSchemaVersion { id @@ -558,6 +566,35 @@ This differs from Request Count because a single request can resolve a field mul
+ + + + Errors + + {props.coordinate} resulted in a GraphQL error {isLoading ? '-' : totalFailures}{' '} + times in {dateRangeController.selectedPreset.label.toLowerCase()}. + + + +
+ {isLoading + ? null + : query.data?.target?.schemaCoordinateStats.errors.edges.map( + ({ node: error }) => ( +
+

{error.code}

+
+
{formatNumber(error.count)}
+
+ {toDecimal((error.count * 100) / totalRequests)}% +
+
+
+ ), + )} +
+
+
From e6cc062993417252d624e48eda3ec25e930137c8 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:31:56 -0700 Subject: [PATCH 40/62] Add separator line on availability bar --- .../target/explorer/availability-bar.tsx | 20 ++-- .../src/components/target/explorer/common.tsx | 103 ++++++++---------- 2 files changed, 56 insertions(+), 67 deletions(-) diff --git a/packages/web/app/src/components/target/explorer/availability-bar.tsx b/packages/web/app/src/components/target/explorer/availability-bar.tsx index 44c1acb2e02..c6be3a17b18 100644 --- a/packages/web/app/src/components/target/explorer/availability-bar.tsx +++ b/packages/web/app/src/components/target/explorer/availability-bar.tsx @@ -1,4 +1,3 @@ -import { TriangleAlertIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; export default function AvailabilityBar({ @@ -13,14 +12,14 @@ export default function AvailabilityBar({ const safePercent = Math.min(Math.max(availability, 0), 100); // Scale the width: 50% availability -> 0% visual width, 100% availability -> 100% visual width const scaledPercent = Math.max((safePercent - 50) * 2, 0); + const isLessThan100 = scaledPercent < 100; // If it's less than 100%, cap the width so at least 1px of red is always visible - const fillWidth = scaledPercent < 100 ? `calc(min(${scaledPercent}%, 100% - 1px))` : '100%'; - const showWarning = safePercent < 50; + const fillWidth = isLessThan100 ? `calc(min(${scaledPercent}%, 100% - 1px))` : '100%'; return (
- {showWarning && ( -
- -
+ className={cn( + 'h-full bg-green-500 transition-all duration-300 ease-in-out', + isLessThan100 && 'border-hive-laboratory-background border-r', )} -
+ style={{ width: fillWidth }} + />
); } diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 8bdc7027e8c..807b105306e 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -64,65 +64,58 @@ export function SchemaExplorerUsageStats(props: {
-
- {formatNumber(usage.total)} - {availability ? ( - - - - - -
-
{capitalize(kindLabel)} Stats
- {hasFieldLevelMetrics ? ( -
- Requests counts how many client requests - asked for a field, whereas Resolutions{' '} - counts the actual number of times your backend executed code to fetch that - field's data (which can multiply within lists or drop to zero if a parent - returned null). -
- ) : null} - - +
{formatNumber(usage.total)}
+ +
+ {availability ? ( + + +
+
{capitalize(kindLabel)} Stats
+ {hasFieldLevelMetrics ? ( +
+ Requests counts how many client requests + asked for a field, whereas Resolutions{' '} + counts the actual number of times your backend executed code to fetch that + field's data (which can multiply within lists or drop to zero if a parent + returned null). +
+ ) : null} +
+ + + + + {usage.totalResolutions ? ( - + - {usage.totalResolutions ? ( - - - - ) : null} - {usage.errorTotal ? ( - - - - ) : null} + ) : null} + {usage.errorTotal ? ( - + - -
+ {usage.total} Requests{' '} + {hasFieldLevelMetrics ? 'with' : null} +
- {usage.total} Requests{' '} - {hasFieldLevelMetrics ? 'with' : null} - {usage.totalResolutions} Resolutions
{usage.totalResolutions} Resolutions
{usage.errorTotal} Errors
- for{' '} - - {availability.toFixed(2)}% Availability - - {usage.errorTotal} Errors
-
-
-
- ) : null} -
-
-
-
+ ) : null} + + + for{' '} + + {availability.toFixed(2)}% Availability + + + + + +
+ + + + + + ) : null}
From 3e3fc1fed7973d14429d51a07a0db526803eee9a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:51:45 -0700 Subject: [PATCH 41/62] rename plugin --- integration-tests/package.json | 2 +- .../plugin.spec.ts | 2 +- .../LICENSE | 0 .../package.json | 2 +- .../src/add-typenames.ts | 0 .../src/index.ts | 0 .../src/is-entity-request.ts | 0 .../src/version.ts | 0 .../tests/add-typenames.spec.ts | 0 .../tests/index.spec.ts | 0 .../tests/is-entity-request.spec.ts | 0 .../tsconfig.json | 0 pnpm-lock.yaml | 11 +++++++---- 13 files changed, 10 insertions(+), 7 deletions(-) rename integration-tests/tests/{gateway-usage => gateway-plugin-console-sdk}/plugin.spec.ts (98%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/LICENSE (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/package.json (96%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/src/add-typenames.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/src/index.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/src/is-entity-request.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/src/version.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/tests/add-typenames.spec.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/tests/index.spec.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/tests/is-entity-request.spec.ts (100%) rename packages/libraries/{gateway-usage => gateway-plugin-console-sdk}/tsconfig.json (100%) diff --git a/integration-tests/package.json b/integration-tests/package.json index e2fd2c5ef1b..82d8269e218 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -19,8 +19,8 @@ "@esm2cjs/execa": "6.1.1-cjs.1", "@graphql-hive/apollo": "workspace:*", "@graphql-hive/core": "workspace:*", + "@graphql-hive/gateway-plugin-console-sdk": "workspace:*", "@graphql-hive/gateway-runtime": "^1.0.0 || ^2.0.0", - "@graphql-hive/gateway-usage": "workspace:*", "@graphql-typed-document-node/core": "3.2.0", "@hive/commerce": "workspace:*", "@hive/postgres": "workspace:*", diff --git a/integration-tests/tests/gateway-usage/plugin.spec.ts b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts similarity index 98% rename from integration-tests/tests/gateway-usage/plugin.spec.ts rename to integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts index 35d22ad6e35..f278a1dcac9 100644 --- a/integration-tests/tests/gateway-usage/plugin.spec.ts +++ b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts @@ -7,8 +7,8 @@ import { initSeed } from 'testkit/seed'; import { getServiceHost } from 'testkit/utils'; import { describe, expect, test } from 'vitest'; import { buildSubgraphSchema } from '@apollo/subgraph'; +import { useHive } from '@graphql-hive/gateway-plugin-console-sdk'; import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; -import { useHive } from '@graphql-hive/gateway-usage'; import { createServer } from '@hive/service-common'; async function createSubgraphService() { diff --git a/packages/libraries/gateway-usage/LICENSE b/packages/libraries/gateway-plugin-console-sdk/LICENSE similarity index 100% rename from packages/libraries/gateway-usage/LICENSE rename to packages/libraries/gateway-plugin-console-sdk/LICENSE diff --git a/packages/libraries/gateway-usage/package.json b/packages/libraries/gateway-plugin-console-sdk/package.json similarity index 96% rename from packages/libraries/gateway-usage/package.json rename to packages/libraries/gateway-plugin-console-sdk/package.json index dc29c4a4cd6..d416433cd76 100644 --- a/packages/libraries/gateway-usage/package.json +++ b/packages/libraries/gateway-plugin-console-sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@graphql-hive/gateway-usage", + "name": "@graphql-hive/gateway-plugin-console-sdk", "version": "0.1.0", "type": "module", "description": "GraphQL Hive + GraphQL Hive Gateway", diff --git a/packages/libraries/gateway-usage/src/add-typenames.ts b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts similarity index 100% rename from packages/libraries/gateway-usage/src/add-typenames.ts rename to packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts diff --git a/packages/libraries/gateway-usage/src/index.ts b/packages/libraries/gateway-plugin-console-sdk/src/index.ts similarity index 100% rename from packages/libraries/gateway-usage/src/index.ts rename to packages/libraries/gateway-plugin-console-sdk/src/index.ts diff --git a/packages/libraries/gateway-usage/src/is-entity-request.ts b/packages/libraries/gateway-plugin-console-sdk/src/is-entity-request.ts similarity index 100% rename from packages/libraries/gateway-usage/src/is-entity-request.ts rename to packages/libraries/gateway-plugin-console-sdk/src/is-entity-request.ts diff --git a/packages/libraries/gateway-usage/src/version.ts b/packages/libraries/gateway-plugin-console-sdk/src/version.ts similarity index 100% rename from packages/libraries/gateway-usage/src/version.ts rename to packages/libraries/gateway-plugin-console-sdk/src/version.ts diff --git a/packages/libraries/gateway-usage/tests/add-typenames.spec.ts b/packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts similarity index 100% rename from packages/libraries/gateway-usage/tests/add-typenames.spec.ts rename to packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts diff --git a/packages/libraries/gateway-usage/tests/index.spec.ts b/packages/libraries/gateway-plugin-console-sdk/tests/index.spec.ts similarity index 100% rename from packages/libraries/gateway-usage/tests/index.spec.ts rename to packages/libraries/gateway-plugin-console-sdk/tests/index.spec.ts diff --git a/packages/libraries/gateway-usage/tests/is-entity-request.spec.ts b/packages/libraries/gateway-plugin-console-sdk/tests/is-entity-request.spec.ts similarity index 100% rename from packages/libraries/gateway-usage/tests/is-entity-request.spec.ts rename to packages/libraries/gateway-plugin-console-sdk/tests/is-entity-request.spec.ts diff --git a/packages/libraries/gateway-usage/tsconfig.json b/packages/libraries/gateway-plugin-console-sdk/tsconfig.json similarity index 100% rename from packages/libraries/gateway-usage/tsconfig.json rename to packages/libraries/gateway-plugin-console-sdk/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823256820e1..54b5ed059f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,12 +317,12 @@ importers: '@graphql-hive/core': specifier: workspace:* version: link:../packages/libraries/core/dist + '@graphql-hive/gateway-plugin-console-sdk': + specifier: workspace:* + version: link:../packages/libraries/gateway-plugin-console-sdk/dist '@graphql-hive/gateway-runtime': specifier: ^1.0.0 || ^2.0.0 version: 2.9.3(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/gateway-usage': - specifier: workspace:* - version: link:../packages/libraries/gateway-usage/dist '@graphql-typed-document-node/core': specifier: 3.2.0 version: 3.2.0(graphql@16.9.0) @@ -654,7 +654,7 @@ importers: version: 16.9.0 publishDirectory: dist - packages/libraries/gateway-usage: + packages/libraries/gateway-plugin-console-sdk: dependencies: '@graphql-hive/core': specifier: workspace:* @@ -665,6 +665,9 @@ importers: graphql: specifier: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 version: 16.9.0 + tiny-lru: + specifier: 11.4.7 + version: 11.4.7 devDependencies: '@envelop/types': specifier: ^5.0.0 From fb97e23d5e88ce2ce4dad2020547beeabbed1f27 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:30:04 -0700 Subject: [PATCH 42/62] remote types from other changes --- packages/services/storage/src/db/types.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 3be6487d7b3..a5bdd6a9fbd 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -282,16 +282,6 @@ export interface projects { validation_url: string | null; } -export interface proposal_approved_changes { - change: any; - hash: string; - id: string; - proposal_id: string; - schema_version_id: string | null; - service: string | null; - target_id: string; -} - export interface saved_filters { created_at: Date; created_by_user_id: string; @@ -567,7 +557,6 @@ export interface DBTables { organizations: organizations; organizations_billing: organizations_billing; projects: projects; - proposal_approved_changes: proposal_approved_changes; saved_filters: saved_filters; schema_change_approvals: schema_change_approvals; schema_checks: schema_checks; From 73fa054ce88cf6cbe21cf317dbf2d1920bbee9b8 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:40:39 -0700 Subject: [PATCH 43/62] Unlock tiny-lru version --- packages/libraries/gateway-plugin-console-sdk/package.json | 5 +++-- pnpm-lock.yaml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/package.json b/packages/libraries/gateway-plugin-console-sdk/package.json index d416433cd76..1dcc565f4e6 100644 --- a/packages/libraries/gateway-plugin-console-sdk/package.json +++ b/packages/libraries/gateway-plugin-console-sdk/package.json @@ -49,10 +49,11 @@ }, "dependencies": { "@graphql-hive/core": "workspace:*", - "tiny-lru": "11.4.7" + "tiny-lru": "*" }, "devDependencies": { - "@envelop/types": "^5.0.0" + "@envelop/types": "^5.0.0", + "tiny-lru": "^11.0.0 || ^12.0.0 || ^13.0.0" }, "publishConfig": { "registry": "https://registry.npmjs.org", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b5ed059f3..c9b50d22105 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -666,7 +666,7 @@ importers: specifier: ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 version: 16.9.0 tiny-lru: - specifier: 11.4.7 + specifier: '*' version: 11.4.7 devDependencies: '@envelop/types': From a9aa6fd1c92bd6a045c72bf6f967f9564bf7f319 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:02:19 -0700 Subject: [PATCH 44/62] Add flag to api to check if field level metrics should be shown --- .../providers/operations-manager.ts | 19 +++++++++++++++ .../operations/providers/operations-reader.ts | 23 +++++++++++++++++++ .../api/src/modules/schema/module.graphql.ts | 5 ++++ .../src/modules/schema/resolvers/Target.ts | 8 +++---- .../src/components/target/explorer/common.tsx | 8 +++---- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 1ab382cde2b..0d589e5628b 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -1446,4 +1446,23 @@ export class OperationsManager { schemaCoordinate, }); } + + async shouldDisplayFieldLevelMetrics({ + organizationId, + targetId, + }: { + organizationId: string; + targetId: string; + }) { + const org = await this.storage.getOrganization({ + organizationId, + }); + + if (org.featureFlags.subgraphVisibility !== true) { + return false; + } + return await this.reader.hasCoordinatesIngestedCheck({ + targetId, + }); + } } diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index f87fcea694c..e465deef87c 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -2229,6 +2229,29 @@ export class OperationsReader { return result.data.map(row => row.client_name); } + /** + * Use the coordinate_counts_daily table to check if any field usage data + * has been ingested for the target. This can be used in conjunction with + * the feature flag to appropriately display frontend components or opt + * to hide them instead. + */ + async hasCoordinatesIngestedCheck({ targetId }: { targetId: string }): Promise { + const result = await this.clickHouse.query<{ + hash: string; + }>({ + query: sql` + SELECT hash FROM coordinate_counts_daily + WHERE target = ${targetId} + LIMIT 1 + `, + queryId: 'has_coordinates_ingested_check', + // set a quick timeout to avoid inconvenience + timeout: 3_000, + }); + + return result.rows === 1; + } + async getCoordinatesOverTime({ targetId, period, diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 78ec345cbf6..b14676d12cb 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -253,6 +253,11 @@ export default gql` """ hasCollectedSubscriptionOperations: Boolean! + """ + Whether this feature has been enabled for the organization and the target + has sent usage data matching the required format. If either of these cases + are false, then this returns false. + """ hasFieldLevelMetrics: Boolean! } diff --git a/packages/services/api/src/modules/schema/resolvers/Target.ts b/packages/services/api/src/modules/schema/resolvers/Target.ts index 50158e4dc24..4b2ab5ec11d 100644 --- a/packages/services/api/src/modules/schema/resolvers/Target.ts +++ b/packages/services/api/src/modules/schema/resolvers/Target.ts @@ -1,6 +1,5 @@ import { parseDateRangeInput } from '../../../shared/helpers'; import { OperationsManager } from '../../operations/providers/operations-manager'; -import { Storage } from '../../shared/providers/storage'; import { isFieldRequestedDeep } from '../lib/is-field-requested'; import { ContractsManager } from '../providers/contracts-manager'; import { SchemaManager } from '../providers/schema-manager'; @@ -126,8 +125,9 @@ export const Target: Pick< }); }, hasFieldLevelMetrics: async (target, _, { injector }) => { - const org = await injector.get(Storage).getOrganization({ organizationId: target.orgId }); - // @TODO also check if any metrics have been sent with the new format. - return org.featureFlags.subgraphVisibility; + return injector.get(OperationsManager).shouldDisplayFieldLevelMetrics({ + organizationId: target.orgId, + targetId: target.id, + }); }, }; diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 807b105306e..75eef6206ff 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -66,8 +66,8 @@ export function SchemaExplorerUsageStats(props: {
{formatNumber(usage.total)}
-
- {availability ? ( + {availability ? ( +
@@ -115,8 +115,8 @@ export function SchemaExplorerUsageStats(props: { - ) : null} -
+
+ ) : null}
From 3a19dd16e69d74c95a90dbfd4cf66d975d7346f4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:43:58 -0700 Subject: [PATCH 45/62] only drop parts in clickhouse to improve ttl efficiency --- .../src/clickhouse-actions/018-usage-coordinate-errors.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts index 837a27c1adf..bd7bb6fc3b1 100644 --- a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts +++ b/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts @@ -148,6 +148,7 @@ export const action: Action = async exec => { SETTINGS index_granularity = 8192 , deduplicate_merge_projection_mode = 'rebuild' + , ttl_only_drop_parts = 1 ; `); @@ -208,6 +209,7 @@ export const action: Action = async exec => { SETTINGS index_granularity = 8192 , deduplicate_merge_projection_mode = 'rebuild' + , ttl_only_drop_parts = 1 ; `); await exec(` @@ -264,6 +266,7 @@ export const action: Action = async exec => { SETTINGS index_granularity = 8192 , deduplicate_merge_projection_mode = 'rebuild' + , ttl_only_drop_parts = 1 ; `); From d71e1454d178bd67b9098bd40add114d9a4fe19c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:22:00 -0700 Subject: [PATCH 46/62] Add test for multiple subgraphs and for an error --- integration-tests/testkit/flow.ts | 40 ++ integration-tests/testkit/seed.ts | 17 + .../gateway-plugin-console-sdk/plugin.spec.ts | 438 +++++++++++++++--- 3 files changed, 419 insertions(+), 76 deletions(-) diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index fb0beea3b65..f9ae7facf7a 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -1442,6 +1442,46 @@ export function readOperationsStats( }); } +export function readSchemaCoordinateStats( + selector: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + schemaCoordinate: string; + }, + period: GraphQLSchema.DateRangeInput, + token: string, +) { + return execute({ + document: graphql(` + query readSchemaCoordinateStats( + $selector: TargetSelectorInput! + $schemaCoordinate: String! + $period: DateRangeInput! + ) { + target(reference: { bySelector: $selector }) { + id + schemaCoordinateStats(schemaCoordinate: $schemaCoordinate, period: $period) { + totalRequests + totalResolutions + totalFailures + } + } + } + `), + token, + variables: { + selector: { + organizationSlug: selector.organizationSlug, + projectSlug: selector.projectSlug, + targetSlug: selector.targetSlug, + }, + schemaCoordinate: selector.schemaCoordinate, + period, + }, + }); +} + export function readOperationBody( selector: { organizationSlug: string; diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 393bfedcfde..fd2faaf8ae7 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -50,6 +50,7 @@ import { readClientStats, readOperationBody, readOperationsStats, + readSchemaCoordinateStats, readTokenInfo, updateBaseSchema, updateMemberRole, @@ -1142,6 +1143,22 @@ export function initSeed() { ) ).expectNoGraphQLErrors(); }, + async readSchemaCoordinateStats( + schemaCoordinate: string, + period: GraphQLSchema.DateRangeInput, + ttarget: TargetOverwrite = target, + ) { + return await readSchemaCoordinateStats( + { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: ttarget.slug, + schemaCoordinate, + }, + period, + ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + }, async readOperationBody(hash: string, ttarget: TargetOverwrite = target) { const operationBodyResult = await readOperationBody( { diff --git a/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts index f278a1dcac9..c108f303377 100644 --- a/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts +++ b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts @@ -1,7 +1,7 @@ import { AddressInfo } from 'node:net'; -import { parse } from 'graphql'; +import { DocumentNode, parse } from 'graphql'; import { createLogger, createYoga } from 'graphql-yoga'; -import { readOperationsStats } from 'testkit/flow'; +import { pollFor, readOperationsStats } from 'testkit/flow'; import { ProjectType } from 'testkit/gql/graphql'; import { initSeed } from 'testkit/seed'; import { getServiceHost } from 'testkit/utils'; @@ -10,38 +10,23 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; import { useHive } from '@graphql-hive/gateway-plugin-console-sdk'; import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; import { createServer } from '@hive/service-common'; +import { composeServices, ServiceDefinition } from '@theguild/federation-composition'; -async function createSubgraphService() { +type ModulesOrSDL = Parameters[0]; + +async function createSubgraphService(name: string, modulesOrSDL: ModulesOrSDL) { const server = await createServer({ sentryErrorHandler: false, log: { requests: false, level: 'silent', }, - name: 'products', + name, }); const yoga = createYoga({ logging: false, - schema: buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - extend type Query { - product: Product - } - - type Product @key(fields: "id") { - id: ID! - price: Int - } - `), - resolvers: { - Query: { - product: () => { - return { id: 1, price: 20.2 }; - }, - }, - }, - }), + schema: buildSubgraphSchema(modulesOrSDL), }); server.route({ @@ -62,55 +47,190 @@ async function createSubgraphService() { }; } +async function setup(subgraphs: { + [key: string]: { + typeDefs: DocumentNode; + resolvers: any; + }; +}) { + const { createOrg } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + await setFeatureFlag('subgraphVisibility', true); + const { createTargetAccessToken, waitForRequestsCollected, readSchemaCoordinateStats, target } = + await createProject(ProjectType.Single); + const token = await createTargetAccessToken({}); + const usageAddress = await getServiceHost('usage', 8081); + const plugin = useHive({ + enabled: true, + token: token.secret, + reporting: false, + usage: true, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + fieldLevelMetricsEnabled: true, + }); + + const services = await Promise.all( + Object.entries(subgraphs).map(async ([name, def]): Promise => { + const service = await createSubgraphService(name, def); + return { + name, + typeDefs: def.typeDefs, + url: service.url, + }; + }), + ); + const supergraph = composeServices(services); + expect(supergraph.errors).toBeUndefined(); + const gateway = createGatewayRuntime({ + supergraph: supergraph.supergraphSdl!, + plugins: () => [plugin], + }); + + return { + target, + gateway, + waitForRequestsCollected, + readSchemaCoordinateStats, + token, + }; +} + describe('GraphQL Hive Plugin', () => { test('usage data includes subgraph request data', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken, waitForRequestsCollected, target } = await createProject( - ProjectType.Single, - ); - const token = await createTargetAccessToken({}); - const usageAddress = await getServiceHost('usage', 8081); - const plugin = useHive({ - enabled: true, - token: token.secret, - reporting: false, - usage: true, - agent: { - logger: createLogger('debug'), - maxSize: 1, + const subgraphs = { + products: { + typeDefs: parse(/* GraphQL */ ` + extend type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! + price: Int + } + `), + resolvers: { + Query: { + product: () => { + return { id: 1, price: 20.2 }; + }, + }, + }, }, - selfHosting: { - usageEndpoint: 'http://' + usageAddress, - graphqlEndpoint: 'http://noop/', - applicationUrl: 'http://noop/', + }; + + const { readSchemaCoordinateStats, target, gateway, token, waitForRequestsCollected } = + await setup(subgraphs); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'app-name', + 'x-graphql-client-version': 'app-version', + 'content-type': 'application/json', + accept: 'application/json', }, - fieldLevelMetricsEnabled: true, + body: JSON.stringify({ + query: ` + { + product { + id + } + } + `, + }), }); - const subgraph = await createSubgraphService(); - const gateway = createGatewayRuntime({ - supergraph: ` - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) - { - query: Query - } - directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @join__type(graph: join__Graph!, key: String) repeatable on OBJECT - directive @link(url: String, as: String, for: String) repeatable on SCHEMA - enum join__Graph { PRODUCTS @join__graph(name: "products", url: "${subgraph.url}") } - - type Query @join__type(graph: PRODUCTS) { - product: Product - } - type Product @join__type(graph: PRODUCTS, key: "id") { - id: ID! - price: Int - } - `, - plugins: () => [plugin], + + const usageCollected = waitForRequestsCollected(1); + const result = await gateway.handle(request); + await expect(result.json()).resolves.toMatchInlineSnapshot(` + { + data: { + product: { + id: 1, + }, + }, + } + `); + await usageCollected; + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const period = { + from: yesterday.toISOString(), + to: new Date().toISOString(), + }; + + await pollFor(async () => { + const operationsStatsResult = await readOperationsStats( + { byId: target.id }, + period, + {}, + token.secret, + ).then(r => r.expectNoGraphQLErrors()); + const stats = await readSchemaCoordinateStats('Query.product', period); + + return ( + stats.target?.schemaCoordinateStats.totalResolutions === 1 && + stats.target?.schemaCoordinateStats.totalRequests === 1 && + stats.target?.schemaCoordinateStats.totalFailures === 0 && + operationsStatsResult.target?.operationsStats.operations.edges[0].node.count === 1 + ); }); + }); + + test('usage data includes subgraph request data, and supports multiple subgraphs', async () => { + const subgraphs = { + products: { + typeDefs: parse(/* GraphQL */ ` + extend type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! + price: Int + } + `), + resolvers: { + Query: { + product: () => { + return { id: 1, price: 20.2 }; + }, + }, + }, + }, + users: { + typeDefs: parse(/* GraphQL */ ` + extend type Query { + users: [User] + } + type User { + id: ID! + name: String + } + `), + resolvers: { + Query: { + users: () => [{ id: 2 }], + }, + User: { + name: () => 'test', + }, + }, + }, + }; + + const { readSchemaCoordinateStats, target, gateway, token, waitForRequestsCollected } = + await setup(subgraphs); const request = new Request('http://localhost:4000/graphql', { method: 'POST', @@ -126,6 +246,10 @@ describe('GraphQL Hive Plugin', () => { product { id } + users { + id + name + } } `, }), @@ -139,6 +263,12 @@ describe('GraphQL Hive Plugin', () => { product: { id: 1, }, + users: [ + { + id: 2, + name: test, + }, + ], }, } `); @@ -146,16 +276,172 @@ describe('GraphQL Hive Plugin', () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - const operationsStatsResult = await readOperationsStats( - { byId: target.id }, - { - from: yesterday.toISOString(), - to: new Date().toISOString(), + const period = { + from: yesterday.toISOString(), + to: new Date().toISOString(), + }; + + await pollFor(async () => { + const operationsStatsResult = await readOperationsStats( + { byId: target.id }, + period, + {}, + token.secret, + ).then(r => r.expectNoGraphQLErrors()); + const stats = await readSchemaCoordinateStats('Query.product', period); + + return ( + stats.target?.schemaCoordinateStats.totalResolutions === 1 && + stats.target?.schemaCoordinateStats.totalRequests === 1 && + stats.target?.schemaCoordinateStats.totalFailures === 0 && + operationsStatsResult.target?.operationsStats.operations.edges[0].node.count === 1 + ); + }); + }); + + test('errors are tracked', async () => { + const subgraphs = { + products: { + typeDefs: parse(/* GraphQL */ ` + extend type Query { + product: Product + } + + type Product @key(fields: "id") { + id: ID! + price: Int + } + `), + resolvers: { + Query: { + product: () => { + return { id: 1, price: 20.2 }; + }, + }, + }, }, - {}, - token.secret, - ).then(r => r.expectNoGraphQLErrors()); - // @TODO after modifying the API, check the additional data (error metrics etc) - expect(operationsStatsResult.target?.operationsStats.operations.edges[0].node.count).toBe(1); + users: { + typeDefs: parse(/* GraphQL */ ` + extend type Query { + users: [User] + } + type User { + id: ID! + name: String + } + `), + resolvers: { + Query: { + users: () => [{ id: 2 }], + }, + User: { + name: () => { + const err = new Error('Something went wrong'); + Object.assign(err, { code: 'OOPSIE' }); + throw err; + }, + }, + }, + }, + }; + + const { readSchemaCoordinateStats, target, gateway, token, waitForRequestsCollected } = + await setup(subgraphs); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'app-name', + 'x-graphql-client-version': 'app-version', + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + query: ` + { + product { + id + } + users { + id + name + } + } + `, + }), + }); + + const usageCollected = waitForRequestsCollected(1); + const result = await gateway.handle(request); + await expect(result.json()).resolves.toMatchInlineSnapshot(` + { + data: { + product: { + id: 1, + }, + users: [ + { + id: 2, + name: null, + }, + ], + }, + errors: [ + { + extensions: { + code: INTERNAL_SERVER_ERROR, + }, + message: Unexpected error., + path: [ + users, + 0, + name, + ], + }, + ], + } + `); + await usageCollected; + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const period = { + from: yesterday.toISOString(), + to: new Date().toISOString(), + }; + + await pollFor(async () => { + const operationsStatsResult = await readOperationsStats( + { byId: target.id }, + period, + {}, + token.secret, + ).then(r => r.expectNoGraphQLErrors()); + const stats = await readSchemaCoordinateStats('Query.product', period); + + return ( + stats.target?.schemaCoordinateStats.totalResolutions === 1 && + stats.target?.schemaCoordinateStats.totalRequests === 1 && + stats.target?.schemaCoordinateStats.totalFailures === 0 && + operationsStatsResult.target?.operationsStats.operations.edges[0].node.count === 1 + ); + }); + + await pollFor(async () => { + const operationsStatsResult = await readOperationsStats( + { byId: target.id }, + period, + {}, + token.secret, + ).then(r => r.expectNoGraphQLErrors()); + const stats = await readSchemaCoordinateStats('User.name', period); + + return ( + stats.target?.schemaCoordinateStats.totalResolutions === 1 && + stats.target?.schemaCoordinateStats.totalRequests === 1 && + stats.target?.schemaCoordinateStats.totalFailures === 1 && + operationsStatsResult.target?.operationsStats.operations.edges[0].node.count === 1 + ); + }); }); }); From a25787b0f935bbc487f60d26897f5130b818392b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:29:30 -0700 Subject: [PATCH 47/62] update lockfile --- pnpm-lock.yaml | 217 +------------------------------------------------ 1 file changed, 3 insertions(+), 214 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e123c0ab5b..0c43bfae695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1730,7 +1730,7 @@ importers: version: 1.1.0(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.4.26 - version: 1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + version: 1.4.26(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -22295,57 +22295,6 @@ snapshots: - winston - ws - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) - '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.12.0) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.22(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@types/node': 25.5.0 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - uWebSockets.js - - winston - - ws - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 @@ -22369,7 +22318,7 @@ snapshots: '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.9.0) '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.9.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.22(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@graphql-tools/wrap': 11.1.15(graphql@16.9.0) '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.1(graphql@16.9.0))(graphql@16.9.0) @@ -22620,44 +22569,6 @@ snapshots: - winston - ws - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/auto-instrumentations-node': 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)) - '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-node': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - supports-color - - uWebSockets.js - - winston - - ws - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': dependencies: '@graphql-hive/core': 0.21.0(graphql@16.9.0)(pino@10.3.0) @@ -23131,37 +23042,6 @@ snapshots: - pino - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.22(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - '@types/node' - - ioredis - - pino - - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 @@ -23176,7 +23056,7 @@ snapshots: '@graphql-tools/executor': 1.5.0(graphql@16.9.0) '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.9.0) '@graphql-tools/merge': 9.1.5(graphql@16.9.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.22(graphql@16.9.0) '@graphql-tools/stitching-directives': 4.0.21(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@graphql-tools/wrap': 11.1.15(graphql@16.9.0) @@ -23224,20 +23104,6 @@ snapshots: - pino - winston - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)': - dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) @@ -23359,25 +23225,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - cache-control-parser: 2.2.0 - graphql: 16.12.0 - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 @@ -23484,25 +23331,6 @@ snapshots: - pino - winston - '@graphql-mesh/transport-common@1.0.16(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) - '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - ioredis - - pino - - winston - '@graphql-mesh/transport-common@1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 @@ -23608,21 +23436,6 @@ snapshots: - utf-8-validate - winston - '@graphql-mesh/types@0.104.28(graphql@16.12.0)': - dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/types@0.104.28(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) @@ -23668,30 +23481,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)': - dependencies: - '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - dset: 3.1.4 - graphql: 16.12.0 - js-yaml: 4.1.1 - lodash.get: 4.4.2 - lodash.topath: 4.5.2 - tiny-lru: 13.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 From bd225d519041e15016d7e2094a3db55cb4136a1c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:53:10 -0700 Subject: [PATCH 48/62] fix type issue; simplify query for test --- integration-tests/testkit/flow.ts | 24 +++++++++++++++++++ integration-tests/testkit/seed.ts | 11 +++------ .../src/pages/target-insights-coordinate.tsx | 2 +- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index f9ae7facf7a..059bea18248 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -1339,6 +1339,30 @@ export function updateBaseSchema(input: UpdateBaseSchemaInput, token: string) { }); } +export function readTotalRequests( + reference: GraphQLSchema.TargetReferenceInput, + period: GraphQLSchema.DateRangeInput, + token: string, +) { + return execute({ + document: graphql(` + query IntegrationTests_ReadTotalRequests( + $reference: TargetReferenceInput! + $period: DateRangeInput! + ) { + target(reference: $reference) { + totalRequests(period: $period) + } + } + `), + token, + variables: { + reference, + period, + }, + }); +} + export function readClientStats( reference: GraphQLSchema.TargetReferenceInput, period: GraphQLSchema.DateRangeInput, diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index fd2faaf8ae7..d65c429d79a 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -52,6 +52,7 @@ import { readOperationsStats, readSchemaCoordinateStats, readTokenInfo, + readTotalRequests, updateBaseSchema, updateMemberRole, updateMetricAlertRule, @@ -1212,7 +1213,7 @@ export function initSeed() { const from = formatISO(opts?.from ?? subHours(Date.now(), 1)); const to = formatISO(opts?.to ?? Date.now()); const check = async () => { - const statsResult = await readOperationsStats( + const statsResult = await readTotalRequests( { bySelector: { organizationSlug: organization.slug, @@ -1224,15 +1225,9 @@ export function initSeed() { from, to, }, - {}, ownerToken, ).then(r => r.expectNoGraphQLErrors()); - const totalRequests = - statsResult.target?.operationsStats.operations.edges.reduce( - (total, edge) => total + edge.node.count, - 0, - ); - return totalRequests == n; + return statsResult.target?.totalRequests == n; }; return pollFor(check); diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index ff8037abb92..e4b784faf1c 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -579,7 +579,7 @@ This differs from Request Count because a single request can resolve a field mul
{isLoading ? null - : query.data?.target?.schemaCoordinateStats.errors.edges.map( + : query.data?.target?.schemaCoordinateStats.errors?.edges.map( ({ node: error }) => (

{error.code}

From 67e2f08b830f280ae3046c2a22833c869a41e161 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:06:34 -0700 Subject: [PATCH 49/62] fix tsconfig --- packages/libraries/gateway-plugin-console-sdk/package.json | 2 +- packages/libraries/gateway-plugin-console-sdk/src/version.ts | 2 +- tsconfig.json | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/package.json b/packages/libraries/gateway-plugin-console-sdk/package.json index 1dcc565f4e6..db8e8b8aa31 100644 --- a/packages/libraries/gateway-plugin-console-sdk/package.json +++ b/packages/libraries/gateway-plugin-console-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/gateway-plugin-console-sdk", - "version": "0.1.0", + "version": "0.0.0", "type": "module", "description": "GraphQL Hive + GraphQL Hive Gateway", "repository": { diff --git a/packages/libraries/gateway-plugin-console-sdk/src/version.ts b/packages/libraries/gateway-plugin-console-sdk/src/version.ts index 7030d4a7c50..5e29e9cf166 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/version.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/version.ts @@ -1 +1 @@ -export const version = '0.1.0'; +export const version = '0.0.0'; diff --git a/tsconfig.json b/tsconfig.json index 8b4f2450176..1aea0522959 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,6 +60,9 @@ "@graphql-hive/external-composition": [ "./packages/libraries/external-composition/src/index.ts" ], + "@graphql-hive/gateway-plugin-console-sdk": [ + "./packages/libraries/gateway-plugin-console-sdk/src/index.ts" + ], "@graphql-hive/core": ["./packages/libraries/core/src/index.ts"], "@graphql-hive/core/src/client/collect-schema-coordinates": [ "./packages/libraries/core/src/client/collect-schema-coordinates.ts" From 49c0378ac19a86d5d238100cd51c76c188272e8f Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:29:19 -0700 Subject: [PATCH 50/62] Automatically determine whether or not to display the resolutions, and if to include a warning --- .../gateway-plugin-console-sdk/plugin.spec.ts | 3 +- .../019-usage-coordinate-counts.ts | 37 +++++++ ...rors.ts => 020-usage-coordinate-errors.ts} | 0 packages/migrations/src/clickhouse.ts | 2 +- .../providers/operations-manager.ts | 98 ++++++++----------- .../operations/providers/operations-reader.ts | 89 ++++++++++++++--- .../api/src/modules/schema/module.graphql.ts | 8 +- .../src/modules/schema/resolvers/Target.ts | 6 +- packages/services/api/src/shared/entities.ts | 1 - packages/services/storage/src/index.ts | 2 - .../src/pages/target-insights-coordinate.tsx | 79 +++++++++------ 11 files changed, 215 insertions(+), 110 deletions(-) rename packages/migrations/src/clickhouse-actions/{018-usage-coordinate-errors.ts => 020-usage-coordinate-errors.ts} (100%) diff --git a/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts index c108f303377..77377ac53d0 100644 --- a/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts +++ b/integration-tests/tests/gateway-plugin-console-sdk/plugin.spec.ts @@ -54,8 +54,7 @@ async function setup(subgraphs: { }; }) { const { createOrg } = await initSeed().createOwner(); - const { createProject, setFeatureFlag } = await createOrg(); - await setFeatureFlag('subgraphVisibility', true); + const { createProject } = await createOrg(); const { createTargetAccessToken, waitForRequestsCollected, readSchemaCoordinateStats, target } = await createProject(ProjectType.Single); const token = await createTargetAccessToken({}); diff --git a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts index 6dabf2999ae..4193c3cff5c 100644 --- a/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts +++ b/packages/migrations/src/clickhouse-actions/019-usage-coordinate-counts.ts @@ -208,4 +208,41 @@ export const action: Action = async exec => { , expires_at ; `); + + /** + * Store the timestamp of the oldest record in a table for quickly querying + * This will not take into account if the target enables and then disabled + * the feature. Once field data is sent, this will forever be considered + * the start time for that target. + */ + await exec(` + CREATE TABLE IF NOT EXISTS default.target_field_level_metrics_onboard_timestamp + ( + target UUID + , timestamp SimpleAggregateFunction(min, DateTime('UTC')) CODEC(DoubleDelta, ZSTD(1)) + ) + ENGINE = AggregatingMergeTree + ORDER BY target + SETTINGS + index_granularity = 8192 + ; + `); + + /** + * Materialized View that updates the oldest timestamp table. + * It hooks directly into the root default.operations table to catch + * timestamps the moment they enter the system. + */ + await exec(` + CREATE MATERIALIZED VIEW IF NOT EXISTS default.mv_target_field_level_metrics_onboard_timestamp + TO default.target_field_level_metrics_onboard_timestamp + AS + SELECT + target + , min(timestamp) AS timestamp + FROM default.operations + WHERE notEmpty(coordinate_totals) + GROUP BY target + ; + `); }; diff --git a/packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts b/packages/migrations/src/clickhouse-actions/020-usage-coordinate-errors.ts similarity index 100% rename from packages/migrations/src/clickhouse-actions/018-usage-coordinate-errors.ts rename to packages/migrations/src/clickhouse-actions/020-usage-coordinate-errors.ts diff --git a/packages/migrations/src/clickhouse.ts b/packages/migrations/src/clickhouse.ts index 5ba28499b5b..390fcab7d55 100644 --- a/packages/migrations/src/clickhouse.ts +++ b/packages/migrations/src/clickhouse.ts @@ -182,8 +182,8 @@ export async function migrateClickHouse( import('./clickhouse-actions/016-subgraph-otel-traces-cleanup'), import('./clickhouse-actions/017-affected-app-deployments-performance'), import('./clickhouse-actions/018-metric-alert-target-rollups'), - import('./clickhouse-actions/018-usage-coordinate-errors'), import('./clickhouse-actions/019-usage-coordinate-counts'), + import('./clickhouse-actions/020-usage-coordinate-errors'), ]); async function actionRunner(action: Action, index: number) { diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 0d589e5628b..e4ec6e9a1d2 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -13,7 +13,7 @@ import type { TargetSelector, } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; -import { OperationsReader } from './operations-reader'; +import { FieldMetricsState, OperationsReader } from './operations-reader'; const DAY_IN_MS = 86_400_000; const lru = new LRU({ @@ -236,15 +236,6 @@ export class OperationsManager { projectId, }, }); - - const org = await this.storage.getOrganization({ - organizationId, - }); - - if (org.featureFlags.subgraphVisibility !== true) { - return null; - } - return this.reader .countCoordinateResolutions({ targetIds: Array.isArray(targetId) ? targetId : [targetId], @@ -306,15 +297,6 @@ export class OperationsManager { projectId, }, }); - - const org = await this.storage.getOrganization({ - organizationId, - }); - - if (!org.featureFlags.subgraphVisibility) { - return null; - } - return this.reader .countCoordinateFailure({ targetIds: Array.isArray(targetId) ? targetId : [targetId], @@ -823,6 +805,7 @@ export class OperationsManager { async readCoordinatesOverTime({ targetId, + projectId, organizationId, period, resolution, @@ -832,14 +815,15 @@ export class OperationsManager { resolution: number; schemaCoordinate: string; } & TargetSelector) { - const org = await this.storage.getOrganization({ + await this.session.assertPerformAction({ + action: 'project:describe', organizationId, + params: { + organizationId, + projectId, + }, }); - if (org.featureFlags.subgraphVisibility !== true) { - return []; - } - return this.reader.getCoordinatesOverTime({ period, resolution, @@ -851,6 +835,7 @@ export class OperationsManager { async readCoordinateFailuresOverTime({ targetId, organizationId, + projectId, period, resolution, schemaCoordinate, @@ -859,14 +844,15 @@ export class OperationsManager { resolution: number; schemaCoordinate: string; } & TargetSelector) { - const org = await this.storage.getOrganization({ + await this.session.assertPerformAction({ + action: 'project:describe', organizationId, + params: { + organizationId, + projectId, + }, }); - if (org.featureFlags.subgraphVisibility !== true) { - return []; - } - return this.reader.getCoordinateFailuresOverTime({ period, resolution, @@ -1290,24 +1276,17 @@ export class OperationsManager { projectId: project, }, }); - - const org = await this.storage.getOrganization({ - organizationId: organization, - }); - const [rows, errorRows] = await Promise.all([ this.reader.countCoordinatesOfType({ target, period, typename, }), - org.featureFlags.subgraphVisibility - ? this.reader.countErrorCoordinatesOfType({ - target, - period, - typename, - }) - : Promise.resolve(), + this.reader.countErrorCoordinatesOfType({ + target, + period, + typename, + }), ]); const records: { @@ -1327,7 +1306,7 @@ export class OperationsManager { }; } - if (org.featureFlags.subgraphVisibility && errorRows) { + if (errorRows) { for (const row of errorRows) { if (records[row.coordinate]) { records[row.coordinate].errorTotal ??= row.total; @@ -1447,22 +1426,29 @@ export class OperationsManager { }); } - async shouldDisplayFieldLevelMetrics({ - organizationId, - targetId, - }: { - organizationId: string; + @cache<{ targetId: string; - }) { - const org = await this.storage.getOrganization({ - organizationId, - }); - - if (org.featureFlags.subgraphVisibility !== true) { - return false; - } - return await this.reader.hasCoordinatesIngestedCheck({ + }>(({ targetId }) => targetId) + @traceFn('OperationManager.fieldLevelMetricsDisplayState', { + initAttributes: input => ({ + 'hive.target.id': input.targetId, + }), + }) + async fieldLevelMetricsDisplayState({ targetId }: { organizationId: string; targetId: string }) { + const state = await this.reader.coordinatesDataSyncedCheck({ targetId, }); + const mappings = { + [FieldMetricsState.HISTORY_MISSING]: 'ON_WITH_WARNING' as const, + [FieldMetricsState.IN_SYNC]: 'ON' as const, + [FieldMetricsState.NO_DATA]: 'OFF' as const, + }; + return mappings[state]; } } + +export enum FieldLevelMetricsDisplayState { + ON, + OFF, + ON_WITH_WARNING, +} diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index ef6dd3ae220..d58c5afd0c7 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -23,7 +23,7 @@ export function formatDate(date: Date): string { return format(addMinutes(date, date.getTimezoneOffset()), 'yyyy-MM-dd HH:mm:ss'); } -function toUnixTimestamp(utcDate: string): any { +function toUnixTimestamp(utcDate: string): number { // 2024-04-26 11:00:00 const [date, time] = utcDate.split(' '); const [year, month, day] = date.split('-'); @@ -49,6 +49,26 @@ const ReadOperationModel = z.object({ type: z.union([z.literal('QUERY'), z.literal('MUTATION'), z.literal('SUBSCRIPTION')]), }); +export enum FieldMetricsState { + /** + * If the field metric usage data is in sync with other usage data, then the visualization can be shown as intended + * with all the data and no explanation needed. + */ + IN_SYNC, + + /** + * If the field metric usage data and operation metric data did not start at the same time, then this requires an + * explanation for why the data does not align. Display a warning on the UI. + */ + HISTORY_MISSING, + + /** + * If the field metric usage data is not available, because the gateway has never installed the necessary plugin, + * then we can safely hide this feature on the frontend. + */ + NO_DATA, +} + type Operation = z.TypeOf; function toDurationMetrics(percentiles: [number, number, number, number], avg: number) { @@ -2235,21 +2255,62 @@ export class OperationsReader { * the feature flag to appropriately display frontend components or opt * to hide them instead. */ - async hasCoordinatesIngestedCheck({ targetId }: { targetId: string }): Promise { - const result = await this.clickHouse.query<{ - hash: string; - }>({ - query: sql` - SELECT hash FROM coordinate_counts_daily - WHERE target = ${targetId} - LIMIT 1 + async coordinatesDataSyncedCheck({ targetId }: { targetId: string }): Promise { + // assume expiration applies equally across the metric tables and so there's no need + // to limit the timestamps + + const startedSendingFieldUsageAt = await this.clickHouse + .query<{ + timestamp: string; + }>({ + query: sql` + SELECT MIN(timestamp) as timestamp + FROM default.target_field_level_metrics_onboard_timestamp + PREWHERE target = ${targetId} `, - queryId: 'has_coordinates_ingested_check', - // set a quick timeout to avoid inconvenience - timeout: 3_000, - }); + queryId: 'coordinate_data_synced_coords', + // set a quick timeout to avoid inconvenience + timeout: 3_000, + }) + .then(r => { + const maybeRow = r.data[0]; + return maybeRow ? toUnixTimestamp(maybeRow.timestamp) : null; + }); + + if (!startedSendingFieldUsageAt) { + return FieldMetricsState.NO_DATA; + } - return result.rows === 1; + const operationsStart = await this.clickHouse + .query<{ + startTime: string; + }>({ + query: sql` + SELECT MIN(timestamp) as startTime + FROM operations_by_target_hourly + PREWHERE + target = ${targetId} + AND timestamp < fromUnixTimestamp64Milli(${sql.raw(String(startedSendingFieldUsageAt))}) + LIMIT 1; + `, + queryId: 'coordinate_data_synced_operations', + // set a quick timeout to avoid inconvenience + timeout: 3_000, + }) + .then(r => { + const maybeRow = r.data[0]; + return maybeRow ? toUnixTimestamp(maybeRow.startTime) : null; + }); + + // check if any operations sent before the adjusted coordinate timestamp + if (operationsStart === null) { + // if no operation is found before this timestamp, then those operation records + // must have been deleted via TTL. In this case, the field level metrics + // and operation metrics are both limited by the TTL and are in sync. + return FieldMetricsState.IN_SYNC; + } + // Else we know coordinate data and operation usage data mismatch. + return FieldMetricsState.HISTORY_MISSING; } async getCoordinatesOverTime({ diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index b14676d12cb..4ac6919312f 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -258,7 +258,13 @@ export default gql` has sent usage data matching the required format. If either of these cases are false, then this returns false. """ - hasFieldLevelMetrics: Boolean! + fieldLevelMetricsDisplayState: FieldLevelMetricsDisplayState! + } + + enum FieldLevelMetricsDisplayState { + ON + OFF + ON_WITH_WARNING } input SchemaChecksFilter { diff --git a/packages/services/api/src/modules/schema/resolvers/Target.ts b/packages/services/api/src/modules/schema/resolvers/Target.ts index 4b2ab5ec11d..77b0b182541 100644 --- a/packages/services/api/src/modules/schema/resolvers/Target.ts +++ b/packages/services/api/src/modules/schema/resolvers/Target.ts @@ -12,8 +12,8 @@ export const Target: Pick< | 'activeContracts' | 'baseSchema' | 'contracts' + | 'fieldLevelMetricsDisplayState' | 'hasCollectedSubscriptionOperations' - | 'hasFieldLevelMetrics' | 'hasSchema' | 'latestSchemaVersion' | 'latestValidSchemaVersion' @@ -124,8 +124,8 @@ export const Target: Pick< organizationId: target.orgId, }); }, - hasFieldLevelMetrics: async (target, _, { injector }) => { - return injector.get(OperationsManager).shouldDisplayFieldLevelMetrics({ + fieldLevelMetricsDisplayState: async (target, _, { injector }) => { + return injector.get(OperationsManager).fieldLevelMetricsDisplayState({ organizationId: target.orgId, targetId: target.id, }); diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 1b3f67461cf..628196269c5 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -156,7 +156,6 @@ export interface Organization { appDeployments: boolean; otelTracing: boolean; schemaProposals: boolean; - subgraphVisibility: boolean; metricAlertRules: boolean; }; zendeskId: string | null; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 0486960a88b..e60dab7e493 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4082,7 +4082,6 @@ const FeatureFlagsModel = z /** whether otel tracing is enabled for the given organization */ otelTracing: z.boolean().default(false), schemaProposals: z.boolean().default(false), - subgraphVisibility: z.boolean().default(false), /** whether metric alert rules are enabled for the given organization */ metricAlertRules: z.boolean().default(false), }) @@ -4096,7 +4095,6 @@ const FeatureFlagsModel = z appDeployments: false, otelTracing: false, schemaProposals: false, - subgraphVisibility: false, metricAlertRules: false, }, ); diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index e4b784faf1c..6402a602bbf 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -23,6 +23,7 @@ import { Meta } from '@/components/ui/meta'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { graphql } from '@/gql'; +import { FieldLevelMetricsDisplayState } from '@/gql/graphql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; import { cn, useChartStyles } from '@/lib/utils'; @@ -39,7 +40,7 @@ const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` target(reference: { bySelector: $targetSelector }) { id hasCollectedSubscriptionOperations - hasFieldLevelMetrics + fieldLevelMetricsDisplayState schemaCoordinateStats(period: $period, schemaCoordinate: $schemaCoordinate) { supergraphMetadata { ...SupergraphMetadataList_SupergraphMetadataFragment @@ -170,7 +171,10 @@ function SchemaCoordinateView(props: { const supergraphMetadata = query.data?.target?.schemaCoordinateStats?.supergraphMetadata; const kind = query.data?.target?.latestValidSchemaVersion?.explorer?.type?.__typename; const title = kind === 'GraphQLEnumType' ? `${typeName} (${props.coordinate})` : props.coordinate; - const hasFieldLevelMetrics = query.data?.target?.hasFieldLevelMetrics; + const fieldLevelMetricsDisplayState = query.data?.target?.fieldLevelMetricsDisplayState; + const showFieldLevelMetrics = + fieldLevelMetricsDisplayState === FieldLevelMetricsDisplayState.On || + fieldLevelMetricsDisplayState === FieldLevelMetricsDisplayState.OnWithWarning; if (query.error) { return ; @@ -227,6 +231,19 @@ function SchemaCoordinateView(props: {
)} + {fieldLevelMetricsDisplayState === FieldLevelMetricsDisplayState.OnWithWarning ? ( +
+ + + Coordinate resolutions were recently added. + + Your gateway was recently upgraded to add usage tracking for actual coordinate + resolutions and errors. Please disregard the missing historic resolution data, as this + data cannot be backfilled. + + +
+ ) : null}
@@ -245,7 +262,7 @@ function SchemaCoordinateView(props: {

- {hasFieldLevelMetrics ? ( + {showFieldLevelMetrics ? ( @@ -567,34 +584,36 @@ This differs from Request Count because a single request can resolve a field mul - - - Errors - - {props.coordinate} resulted in a GraphQL error {isLoading ? '-' : totalFailures}{' '} - times in {dateRangeController.selectedPreset.label.toLowerCase()}. - - - -
- {isLoading - ? null - : query.data?.target?.schemaCoordinateStats.errors?.edges.map( - ({ node: error }) => ( -
-

{error.code}

-
-
{formatNumber(error.count)}
-
- {toDecimal((error.count * 100) / totalRequests)}% + {showFieldLevelMetrics ? ( + + + Errors + + {props.coordinate} resulted in a GraphQL error {isLoading ? '-' : totalFailures}{' '} + times in {dateRangeController.selectedPreset.label.toLowerCase()}. + + + +
+ {isLoading + ? null + : query.data?.target?.schemaCoordinateStats.errors?.edges.map( + ({ node: error }) => ( +
+

{error.code}

+
+
{formatNumber(error.count)}
+
+ {toDecimal((error.count * 100) / totalRequests)}% +
-
- ), - )} -
- - + ), + )} +
+ + + ) : null}
From ed976571c91b7a72625a5872e806d47f4e292bdf Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:22:39 -0700 Subject: [PATCH 51/62] Use info colors --- packages/web/app/src/pages/target-insights-coordinate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index 6402a602bbf..f6cc761a3ae 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -233,7 +233,7 @@ function SchemaCoordinateView(props: { )} {fieldLevelMetricsDisplayState === FieldLevelMetricsDisplayState.OnWithWarning ? (
- + Coordinate resolutions were recently added. From e5d8e751052d9e5d15395c06491831c4dd46bb2a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:05:10 -0700 Subject: [PATCH 52/62] type and spec terms cleanup --- packages/libraries/core/src/client/usage.ts | 38 ++++++++----------- .../subrequests/extract-coordinates.spec.ts | 2 +- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index a7f76496f18..42e212bd1ef 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -42,7 +42,7 @@ interface UsageCollector { duration: number; experimental__persistedDocumentHash?: string; /** Optionally send subgraph request information. This provides a deeper level of usage metrics */ - fetches?: CollectedOperationSubgraphRequest[] | null; + fetches?: CollectedOperationSubRequest[] | null; }): void; /** collect a long-lived GraphQL request/subscription (subscription operation) */ collectSubscription(args: { @@ -126,7 +126,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl set(action) { if (action.type === 'request') { const operation = action.data; - const fetches = operation.execution.fetches?.map((f): OperationSubgraphRequest => { + const fetches = operation.execution.fetches?.map((f): OperationSubRequest => { const documentRoot = f.document.definitions.find( (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition', )?.operation satisfies 'subscription' | 'mutation' | 'query' | undefined; @@ -346,7 +346,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl */ collect() { const sinceStart = measureDuration(); - const subRequests: CollectedOperationSubgraphRequest[] = []; + const subRequests: CollectedOperationSubRequest[] = []; return { subrequest({ subgraph, type, paths }) { const start = sinceStart(); @@ -484,13 +484,6 @@ export function createCollector({ ); } -export interface Report { - size: number; - map: OperationMap; - operations?: RequestOperation[]; - subscriptionOperations?: SubscriptionOperation[]; -} - type AgentAction = | { type: 'request'; @@ -501,7 +494,14 @@ type AgentAction = data: CollectedSubscriptionOperation; }; -type OperationSubgraphRequest = { +export interface Report { + size: number; + map: OperationMap; + operations?: RequestOperation[]; + subscriptionOperations?: SubscriptionOperation[]; +} + +type OperationSubRequest = { /** Delta start time from "timestamp" */ start: number; @@ -533,7 +533,7 @@ type OperationSubgraphRequest = { type: 'ROOT' | 'ENTITY'; }; -type CollectedOperationSubgraphRequest = { +type CollectedOperationSubRequest = { /** Delta start time from "timestamp" */ start: number; @@ -578,7 +578,7 @@ interface CollectedOperation { ok: boolean; duration: number; errorsTotal: number; - fetches?: CollectedOperationSubgraphRequest[] | null; + fetches?: CollectedOperationSubRequest[] | null; }; persistedDocumentHash?: string; client?: ClientInfo | null; @@ -601,14 +601,11 @@ interface RequestOperation { ok: boolean; duration: number; errorsTotal: number; - fetches?: OperationSubgraphRequest[] | null; + fetches?: OperationSubRequest[] | null; }; persistedDocumentHash?: string; metadata?: { - client?: { - name: string; - version: string; - }; + client?: ClientInfo; }; } @@ -617,10 +614,7 @@ interface SubscriptionOperation { timestamp: number; persistedDocumentHash?: string; metadata?: { - client?: { - name: string; - version: string; - }; + client?: ClientInfo; }; } diff --git a/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts b/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts index 28160216ead..3ce298ba906 100644 --- a/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts +++ b/packages/libraries/core/tests/client/subrequests/extract-coordinates.spec.ts @@ -175,7 +175,7 @@ describe('extractCoordinates', () => { }); describe('Null Handling', () => { - it('Gracefully skips unresolvable or null fields', () => { + it('Includes null values in fields but not the returnType of that field', () => { const schema = buildSchema(` type Query { user: User } type User { id: ID, name: String } From 8d31d1f4b85d27778c7d7e6d162ce27501903a4b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:40:42 -0700 Subject: [PATCH 53/62] Fix name and add changeset --- .changeset/tricky-items-sit.md | 7 +++++++ packages/libraries/gateway-plugin-console-sdk/package.json | 2 +- packages/libraries/gateway-plugin-console-sdk/src/index.ts | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/tricky-items-sit.md diff --git a/.changeset/tricky-items-sit.md b/.changeset/tricky-items-sit.md new file mode 100644 index 00000000000..0fb8496f1cf --- /dev/null +++ b/.changeset/tricky-items-sit.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/gateway-plugin-console-sdk': minor +'@graphql-hive/core': minor +'hive': minor +--- + +Support coordinate level resolution and error reporting diff --git a/packages/libraries/gateway-plugin-console-sdk/package.json b/packages/libraries/gateway-plugin-console-sdk/package.json index db8e8b8aa31..164ad678289 100644 --- a/packages/libraries/gateway-plugin-console-sdk/package.json +++ b/packages/libraries/gateway-plugin-console-sdk/package.json @@ -6,7 +6,7 @@ "repository": { "type": "git", "url": "graphql-hive/platform", - "directory": "packages/libraries/gateway" + "directory": "packages/libraries/gateway-plugin-console-sdk" }, "homepage": "https://the-guild.dev/graphql/hive", "author": { diff --git a/packages/libraries/gateway-plugin-console-sdk/src/index.ts b/packages/libraries/gateway-plugin-console-sdk/src/index.ts index ab422eed774..bb7bf40c8c6 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/index.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/index.ts @@ -17,7 +17,7 @@ export function createHive(clientOrOptions: HivePluginOptions) { return createHiveClient({ ...clientOrOptions, agent: { - name: 'hive-client-yoga', + name: 'hive-client-gateway-sdk', version, ...clientOrOptions.agent, }, @@ -42,7 +42,7 @@ export function useHive(clientOrOptions: HiveClient | GatewayPluginOptions): Gat : createHive({ ...clientOrOptions, agent: { - name: 'hive-client-envelop', + name: 'hive-client-gateway-sdk', ...clientOrOptions.agent, }, }); From bf8711f952afb55105ed297566ef6da928af2483 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:03:02 -0700 Subject: [PATCH 54/62] Add error codes over time query --- .../src/modules/operations/module.graphql.ts | 9 ++- .../providers/operations-manager.ts | 55 ++++++++++++++ .../operations/providers/operations-reader.ts | 72 ++++++++++++++++++- .../resolvers/SchemaCoordinateStats.ts | 28 +++++++- .../src/pages/target-insights-coordinate.tsx | 4 +- 5 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index e69d76f13de..d0a96e502cc 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -200,7 +200,14 @@ export default gql` operations: OperationStatsValuesConnection! @tag(name: "public") clients: ClientStatsValuesConnection! @tag(name: "public") - errors: ErrorStatsValuesConnection + errorCodes: ErrorStatsValuesConnection + errorCodesOverTime: [ErrorCodesOverTime!] + } + + type ErrorCodesOverTime { + code: String! + date: DateTime! + count: SafeInt! } type OperationsStats { diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index e4ec6e9a1d2..51e0889886a 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -756,6 +756,61 @@ export class OperationsManager { }); } + @cache< + { + period: DateRange; + } & TargetSelector + >(selector => JSON.stringify(selector)) + @traceFn('OperationManager.readErrorCodesOverTimeAtSchemaCoordinate', { + initAttributes: input => ({ + 'hive.organization.id': input.organizationId, + 'hive.project.id': input.projectId, + 'hive.target.id': input.targetId, + }), + }) + async readErrorCodesOverTimeAtSchemaCoordinate({ + period, + resolution, + organizationId: organization, + projectId: project, + targetId: target, + schemaCoordinate, + }: { + period: DateRange; + resolution: number; + operations?: readonly string[]; + schemaCoordinate: string; + } & TargetSelector): Promise< + { + code: string; + date: number; + count: number; + }[] + > { + this.logger.info( + 'Reading error codes over time (period=%o, resolution=%s, target=%s, schemaCoordinate=%s)', + period, + resolution, + target, + schemaCoordinate, + ); + await this.session.assertPerformAction({ + action: 'project:describe', + organizationId: organization, + params: { + organizationId: organization, + projectId: project, + }, + }); + + return this.reader.errorCodesOverTimeAtSchemaCoordinate({ + target, + period, + resolution, + schemaCoordinate, + }); + } + async readDurationOverTime({ period, resolution, diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index d58c5afd0c7..e2c50433b65 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -2986,8 +2986,76 @@ export class OperationsReader { period, }), ); - // @TODO safe parse - return result.data; + return result.data.map(d => ({ + count: ensureNumber(d.count), + code: d.code, + })); + } + + async errorCodesOverTimeAtSchemaCoordinate({ + schemaCoordinate, + target, + period, + resolution, + }: { + schemaCoordinate: string; + target: string; + period: { + from: Date; + to: Date; + }; + resolution: number; + }) { + const interval = calculateTimeWindow({ period, resolution }); + const intervalRaw = this.clickHouse.translateWindow(interval); + const roundedPeriod = { + from: toStartOfInterval(period.from, interval.value, interval.unit), + to: toEndOfInterval(period.to, interval.value, interval.unit), + }; + const startDateTimeFormatted = formatDate(roundedPeriod.from); + const endDateTimeFormatted = formatDate(roundedPeriod.to); + + const result = await this.clickHouse.query<{ + date: string; + code: string; + count: string; + }>( + this.pickAggregationByPeriod({ + query: aggregationTableName => sql` + SELECT + toDateTime( + intDiv( + toUnixTimestamp(timestamp), + toUInt32(${String(interval.seconds)}) + ) * toUInt32(${String(interval.seconds)}) + ) as date, + code, + sum(total_errors) as count + FROM ${aggregationTableName('coordinate_errors')} + ${this.createFilter({ + period: roundedPeriod, + target, + extra: [sql`coordinate=${schemaCoordinate}`], + })} + GROUP BY code, timestamp + ORDER BY date + WITH FILL + FROM toDateTime(${startDateTimeFormatted}, 'UTC') + TO toDateTime(${endDateTimeFormatted}, 'UTC') + STEP INTERVAL ${intervalRaw} + `, + queryId: aggregation => `error_codes_over_time_at_${aggregation}`, + timeout: 15_000, + period, + resolution, + }), + ); + + return result.data.map(row => ({ + code: row.code, + date: toUnixTimestamp(row.date), + count: ensureNumber(row.count), + })); } public createFilter({ diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index 522410bcc17..62885ee0763 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -5,7 +5,8 @@ import type { SchemaCoordinateStatsResolvers } from './../../../__generated__/ty export const SchemaCoordinateStats: Pick< SchemaCoordinateStatsResolvers, | 'clients' - | 'errors' + | 'errorCodes' + | 'errorCodesOverTime' | 'failuresOverTime' | 'operations' | 'requestsOverTime' @@ -163,7 +164,11 @@ export const SchemaCoordinateStats: Pick< schemaCoordinate, }); }, - errors: async ({ organization, project, target, period, schemaCoordinate }, _, { injector }) => { + errorCodes: async ( + { organization, project, target, period, schemaCoordinate }, + _, + { injector }, + ) => { const nodes = await injector.get(OperationsManager).errorCodesAtSchemaCoordinate({ organizationId: organization, projectId: project, @@ -181,4 +186,23 @@ export const SchemaCoordinateStats: Pick< }, }; }, + errorCodesOverTime: async ( + { organization, project, target, period, schemaCoordinate }, + { resolution }, + { injector }, + ) => { + // Failures are tracked to fields and not types. Don't bother doing a lookup for types. + if (!schemaCoordinate.includes('.')) { + return null; + } + + return injector.get(OperationsManager).readErrorCodesOverTimeAtSchemaCoordinate({ + targetId: target, + projectId: project, + organizationId: organization, + period, + resolution, + schemaCoordinate, + }); + }, }; diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index f6cc761a3ae..71479401a99 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -78,7 +78,7 @@ const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` } } } - errors { + errorCodes { edges { node { code @@ -597,7 +597,7 @@ This differs from Request Count because a single request can resolve a field mul
{isLoading ? null - : query.data?.target?.schemaCoordinateStats.errors?.edges.map( + : query.data?.target?.schemaCoordinateStats.errorCodes?.edges.map( ({ node: error }) => (

{error.code}

From be1ead7edfea560c8e3083bddc3956f690dac2d9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:45:37 -0700 Subject: [PATCH 55/62] Add error activity over time --- .../src/modules/operations/module.graphql.ts | 2 +- .../operations/providers/operations-reader.ts | 4 +- packages/web/app/src/lib/utils.ts | 21 +++ .../src/pages/target-insights-coordinate.tsx | 142 ++++++++++++++---- 4 files changed, 139 insertions(+), 30 deletions(-) diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index d0a96e502cc..f03cf90492e 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -201,7 +201,7 @@ export default gql` operations: OperationStatsValuesConnection! @tag(name: "public") clients: ClientStatsValuesConnection! @tag(name: "public") errorCodes: ErrorStatsValuesConnection - errorCodesOverTime: [ErrorCodesOverTime!] + errorCodesOverTime(resolution: Int!): [ErrorCodesOverTime!] } type ErrorCodesOverTime { diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index e2c50433b65..f2faa95c777 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -3037,8 +3037,8 @@ export class OperationsReader { target, extra: [sql`coordinate=${schemaCoordinate}`], })} - GROUP BY code, timestamp - ORDER BY date + GROUP BY code, date + ORDER BY code, date WITH FILL FROM toDateTime(${startDateTimeFormatted}, 'UTC') TO toDateTime(${endDateTimeFormatted}, 'UTC') diff --git a/packages/web/app/src/lib/utils.ts b/packages/web/app/src/lib/utils.ts index 5487fd2b518..548a790e295 100644 --- a/packages/web/app/src/lib/utils.ts +++ b/packages/web/app/src/lib/utils.ts @@ -23,6 +23,27 @@ function hslToHex(h: number, s: number, l: number): string { return `#${f(0)}${f(8)}${f(4)}`; } +export function stringToHiveColor(str: string): string { + let hash = 0; + + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + const hue = Math.abs(hash) % 360; + const saturation = 75; + const lightness = 65; + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + +// export function foregroundColor(str: string, theme: 'light' | 'dark') { +// const h = stringToHue(str); +// return theme === 'light' +// ? { backgroundColor: `hsl(${h}, 75%, 72%)`, color: '#f2f2f2' } +// : { backgroundColor: `hsl(${h}, 60%, 90%)`, color: '#4f4f4f' }; +// } + function readChartStyles() { const s = getComputedStyle(document.documentElement); const textColor = s.getPropertyValue('--color-neutral-12').trim(); diff --git a/packages/web/app/src/pages/target-insights-coordinate.tsx b/packages/web/app/src/pages/target-insights-coordinate.tsx index 71479401a99..209b2708e90 100644 --- a/packages/web/app/src/pages/target-insights-coordinate.tsx +++ b/packages/web/app/src/pages/target-insights-coordinate.tsx @@ -26,7 +26,7 @@ import { graphql } from '@/gql'; import { FieldLevelMetricsDisplayState } from '@/gql/graphql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; -import { cn, useChartStyles } from '@/lib/utils'; +import { cn, stringToHiveColor, useChartStyles } from '@/lib/utils'; import { Link } from '@tanstack/react-router'; const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` @@ -86,6 +86,11 @@ const SchemaCoordinateView_SchemaCoordinateStatsQuery = graphql(` } } } + errorCodesOverTime(resolution: $resolution) { + code + count + date + } } latestValidSchemaVersion { id @@ -107,6 +112,7 @@ function SchemaCoordinateView(props: { targetSlug: string; }) { const { styles, colors } = useChartStyles(); + const errorColors = [colors.error, colors.p99, colors.p95, colors.p90, colors.p75]; const dateRangeController = useDateRangeController({ dataRetentionInDays: props.dataRetentionInDays, defaultPreset: presetLast7Days, @@ -162,6 +168,19 @@ function SchemaCoordinateView(props: { return errorPoints.map(node => [node.date, node.value]); }, [errorPoints]); + + const errorCodesOverTime = useMemo(() => { + const items = query.data?.target?.schemaCoordinateStats.errorCodesOverTime ?? []; + return items.reduce( + (grouped, item) => { + grouped[item.code] ??= []; + grouped[item.code].push([item.date, item.count]); + + return grouped; + }, + {} as Record, + ); + }, [query.data?.target?.schemaCoordinateStats.errorCodesOverTime]); const totalRequests = query.data?.target?.schemaCoordinateStats?.totalRequests ?? 0; const totalResolutions = query.data?.target?.schemaCoordinateStats?.totalResolutions ?? 0; const totalFailures = query.data?.target?.schemaCoordinateStats.totalFailures ?? null; @@ -585,34 +604,103 @@ This differs from Request Count because a single request can resolve a field mul {showFieldLevelMetrics ? ( - - - Errors - - {props.coordinate} resulted in a GraphQL error {isLoading ? '-' : totalFailures}{' '} - times in {dateRangeController.selectedPreset.label.toLowerCase()}. - - - -
- {isLoading - ? null - : query.data?.target?.schemaCoordinateStats.errorCodes?.edges.map( - ({ node: error }) => ( -
-

{error.code}

-
-
{formatNumber(error.count)}
-
- {toDecimal((error.count * 100) / totalRequests)}% + <> + + + Errors + + {props.coordinate} resulted in a GraphQL error {isLoading ? '-' : totalFailures}{' '} + times in {dateRangeController.selectedPreset.label.toLowerCase()}. + + + +
+ {isLoading + ? null + : query.data?.target?.schemaCoordinateStats.errorCodes?.edges.map( + ({ node: error }) => ( +
+

{error.code}

+
+
{formatNumber(error.count)}
+
+ {toDecimal((error.count * 100) / totalRequests)}% +
-
- ), - )} -
- - + ), + )} +
+ + + + + Error Activity + + Error codes returned by {props.coordinate} over time + + + + + {size => ( + formatNumber(value), + }, + }, + ], + series: Object.keys(errorCodesOverTime).map((errorCode, i) => ({ + type: 'bar', + name: errorCode ?? 'undefined', + showSymbol: false, + smooth: false, + color: i < 5 ? errorColors[i] : stringToHiveColor(errorCode), + areaStyle: {}, + emphasis: { + focus: 'series', + }, + large: true, + data: errorCodesOverTime[errorCode], + })), + }} + /> + )} + + + + ) : null}
From ce926bced9eb73c874d6cb3985c8a860ddf2a783 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:18:51 -0700 Subject: [PATCH 56/62] Remove unnecessary allocations from add-typename for performance --- .../src/add-typenames.ts | 220 +++++++++--------- 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts index 56ef1f3cde2..752d62d11c8 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts @@ -2,6 +2,7 @@ import { getNamedType, isAbstractType, isCompositeType, + isInterfaceType, isObjectType, Kind, SchemaMetaFieldDef, @@ -15,6 +16,10 @@ import { type SelectionSetNode, } from 'graphql'; +type Mutable = { + -readonly [K in keyof T]: T[K]; +}; + const TYPENAME_FIELD: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' }, @@ -35,8 +40,15 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc const subscriptionType = schema.getSubscriptionType(); let definitionsChanged = false; + const defs = document.definitions; + const len = defs.length; + + let newDefs: Mutable | null = null; + + for (let i = 0; i < len; i++) { + const def = defs[i]; + let newDef = def; - const augmentedDefinitions = document.definitions.map(def => { if (def.kind === Kind.OPERATION_DEFINITION) { const rootType = def.operation === 'query' @@ -45,41 +57,38 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc ? mutationType : subscriptionType; - if (!rootType) return def; - - const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); - if (newSelectionSet !== def.selectionSet) { - definitionsChanged = true; - return { - ...def, - selectionSet: newSelectionSet, - }; + if (rootType) { + const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); + if (newSelectionSet !== def.selectionSet) { + newDef = { ...def, selectionSet: newSelectionSet }; + } + } + } else if (def.kind === Kind.FRAGMENT_DEFINITION) { + const onType = schema.getType(def.typeCondition.name.value); + if (onType && isCompositeType(onType)) { + const newSelectionSet = walkSelectionSet( + def.selectionSet, + onType, + isAbstractType(onType), + schema, + ); + if (newSelectionSet !== def.selectionSet) { + newDef = { ...def, selectionSet: newSelectionSet }; + } } - return def; } - if (def.kind === Kind.FRAGMENT_DEFINITION) { - const onType = schema.getType(def.typeCondition.name.value); - if (!onType || !isCompositeType(onType)) return def; - - const newSelectionSet = walkSelectionSet( - def.selectionSet, - onType, - isAbstractType(onType), - schema, - ); - if (newSelectionSet !== def.selectionSet) { + if (newDef !== def) { + if (!definitionsChanged) { definitionsChanged = true; - return { - ...def, - selectionSet: newSelectionSet, - }; + newDefs = defs.slice(0, i); } - return def; } - return def; - }); + if (definitionsChanged) { + newDefs!.push(newDef); + } + } if (!definitionsChanged) { return document; @@ -87,19 +96,10 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc return { ...document, - definitions: augmentedDefinitions, + definitions: newDefs!, }; } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * @param parentIsAbstract - true when the immediately enclosing field (or - * fragment condition) resolved to an abstract type. This is propagated into - * concrete inline-fragment branches so they also receive __typename. - */ function walkSelectionSet( selectionSet: SelectionSetNode, parentType: GraphQLCompositeType, @@ -107,109 +107,107 @@ function walkSelectionSet( schema: GraphQLSchema, ): SelectionSetNode { const thisTypeIsAbstract = isAbstractType(parentType); + const mightNeedTypename = thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType)); - // Inject __typename when: - // 1. This selection set is typed as an abstract type directly, OR - // 2. This is a concrete inline-fragment branch inside an abstract parent - // (parentIsAbstract && isObjectType), so the concrete type is - // identifiable only with __typename at runtime. - const needsTypename = - (thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType))) && - !selectionSet.selections.some(s => s.kind === Kind.FIELD && s.name.value === '__typename'); - + let hasTypename = false; let selectionsChanged = false; - const augmentedSelections = selectionSet.selections.map(selection => { - if (selection.kind === Kind.FIELD) { - if (!selection.selectionSet) { - return selection; // Scalar or enum leaf. - } + const selections = selectionSet.selections; + const len = selections.length; - const fieldDef = getFieldDef(schema, parentType, selection); - if (!fieldDef) return selection; + let newSelections: Mutable | null = null; - const fieldType = getNamedType(fieldDef.type); - if (!fieldType || !isCompositeType(fieldType)) return selection; + for (let i = 0; i < len; i++) { + const selection = selections[i]; + let newSelection = selection; - const newSelectionSet = walkSelectionSet( - selection.selectionSet, - fieldType, - isAbstractType(fieldType), - schema, - ); - - if (newSelectionSet !== selection.selectionSet) { - selectionsChanged = true; - return { - ...selection, - selectionSet: newSelectionSet, - } satisfies FieldNode; + if (selection.kind === Kind.FIELD) { + if (mightNeedTypename && !hasTypename && selection.name.value === '__typename') { + hasTypename = true; } - return selection; - } - - if (selection.kind === Kind.INLINE_FRAGMENT) { - // A typed fragment (`... on Foo`) narrows the parent type. - // An untyped fragment inherits the parent type. + if (selection.selectionSet) { + const fieldDef = getFieldDef(schema, parentType, selection); + if (fieldDef) { + const fieldType = getNamedType(fieldDef.type); + if (fieldType && isCompositeType(fieldType)) { + const newChildSelectionSet = walkSelectionSet( + selection.selectionSet, + fieldType, + isAbstractType(fieldType), + schema, + ); + + if (newChildSelectionSet !== selection.selectionSet) { + newSelection = { ...selection, selectionSet: newChildSelectionSet } as FieldNode; + } + } + } + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { const branchType = selection.typeCondition ? schema.getType(selection.typeCondition.name.value) : parentType; - if (!branchType || !isCompositeType(branchType)) return selection; - - const newSelectionSet = walkSelectionSet( - selection.selectionSet, - branchType, - // Pass through whether the enclosing field was abstract, so that - // concrete branches (... on User inside a Node field) still get - // __typename injected into their own selection set. - thisTypeIsAbstract || parentIsAbstract, - schema, - ); + if (branchType && isCompositeType(branchType)) { + const newChildSelectionSet = walkSelectionSet( + selection.selectionSet, + branchType, + thisTypeIsAbstract || parentIsAbstract, + schema, + ); + + if (newChildSelectionSet !== selection.selectionSet) { + newSelection = { ...selection, selectionSet: newChildSelectionSet } as InlineFragmentNode; + } + } + } - if (newSelectionSet !== selection.selectionSet) { + if (newSelection !== selection) { + if (!selectionsChanged) { selectionsChanged = true; - return { - ...selection, - selectionSet: newSelectionSet, - } satisfies InlineFragmentNode; + newSelections = selections.slice(0, i); } + } - return selection; + if (selectionsChanged) { + newSelections!.push(newSelection); } + } - // FRAGMENT_SPREAD — the definition is handled at the top-level definitions - // pass; the spread node itself carries no selectionSet. - return selection; - }); + const needsTypename = mightNeedTypename && !hasTypename; - if (!needsTypename && !selectionsChanged) { + if (!selectionsChanged && !needsTypename) { return selectionSet; } + // If we only need to append __typename but no children changed, we clone the array here. + const finalSelections = selectionsChanged ? newSelections! : selections.slice(); + + if (needsTypename) { + finalSelections.push(TYPENAME_FIELD); + } + return { ...selectionSet, - selections: needsTypename ? [...augmentedSelections, TYPENAME_FIELD] : augmentedSelections, + selections: finalSelections, }; } -/** - * Resolves the field definition for a given field node on a parent type, - * including the meta-fields __schema, __type, and __typename. - */ function getFieldDef(schema: GraphQLSchema, parentType: GraphQLCompositeType, field: FieldNode) { const name = field.name.value; - if (name === '__schema' && parentType === schema.getQueryType()) { - return SchemaMetaFieldDef; - } - if (name === '__type' && parentType === schema.getQueryType()) { - return TypeMetaFieldDef; + if (name === '__typename') return TypeNameMetaFieldDef; + + if (name === '__schema' || name === '__type') { + if (parentType === schema.getQueryType()) { + return name === '__schema' ? SchemaMetaFieldDef : TypeMetaFieldDef; + } } - if (name === '__typename') { - return TypeNameMetaFieldDef; + + if (isObjectType(parentType) || isInterfaceType(parentType)) { + return parentType.getFields()[name] ?? null; } - return 'getFields' in parentType ? (parentType.getFields()[name] ?? null) : null; + return null; } From 9e65db9970cb28617a31cb7de0cecf13b7aa914f Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:42:15 -0700 Subject: [PATCH 57/62] Revert changes due to failing test --- .../src/add-typenames.ts | 220 +++++++++--------- 1 file changed, 111 insertions(+), 109 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts index 752d62d11c8..56ef1f3cde2 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts @@ -2,7 +2,6 @@ import { getNamedType, isAbstractType, isCompositeType, - isInterfaceType, isObjectType, Kind, SchemaMetaFieldDef, @@ -16,10 +15,6 @@ import { type SelectionSetNode, } from 'graphql'; -type Mutable = { - -readonly [K in keyof T]: T[K]; -}; - const TYPENAME_FIELD: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' }, @@ -40,15 +35,8 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc const subscriptionType = schema.getSubscriptionType(); let definitionsChanged = false; - const defs = document.definitions; - const len = defs.length; - - let newDefs: Mutable | null = null; - - for (let i = 0; i < len; i++) { - const def = defs[i]; - let newDef = def; + const augmentedDefinitions = document.definitions.map(def => { if (def.kind === Kind.OPERATION_DEFINITION) { const rootType = def.operation === 'query' @@ -57,38 +45,41 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc ? mutationType : subscriptionType; - if (rootType) { - const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); - if (newSelectionSet !== def.selectionSet) { - newDef = { ...def, selectionSet: newSelectionSet }; - } - } - } else if (def.kind === Kind.FRAGMENT_DEFINITION) { - const onType = schema.getType(def.typeCondition.name.value); - if (onType && isCompositeType(onType)) { - const newSelectionSet = walkSelectionSet( - def.selectionSet, - onType, - isAbstractType(onType), - schema, - ); - if (newSelectionSet !== def.selectionSet) { - newDef = { ...def, selectionSet: newSelectionSet }; - } + if (!rootType) return def; + + const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); + if (newSelectionSet !== def.selectionSet) { + definitionsChanged = true; + return { + ...def, + selectionSet: newSelectionSet, + }; } + return def; } - if (newDef !== def) { - if (!definitionsChanged) { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + const onType = schema.getType(def.typeCondition.name.value); + if (!onType || !isCompositeType(onType)) return def; + + const newSelectionSet = walkSelectionSet( + def.selectionSet, + onType, + isAbstractType(onType), + schema, + ); + if (newSelectionSet !== def.selectionSet) { definitionsChanged = true; - newDefs = defs.slice(0, i); + return { + ...def, + selectionSet: newSelectionSet, + }; } + return def; } - if (definitionsChanged) { - newDefs!.push(newDef); - } - } + return def; + }); if (!definitionsChanged) { return document; @@ -96,10 +87,19 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc return { ...document, - definitions: newDefs!, + definitions: augmentedDefinitions, }; } +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * @param parentIsAbstract - true when the immediately enclosing field (or + * fragment condition) resolved to an abstract type. This is propagated into + * concrete inline-fragment branches so they also receive __typename. + */ function walkSelectionSet( selectionSet: SelectionSetNode, parentType: GraphQLCompositeType, @@ -107,107 +107,109 @@ function walkSelectionSet( schema: GraphQLSchema, ): SelectionSetNode { const thisTypeIsAbstract = isAbstractType(parentType); - const mightNeedTypename = thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType)); - let hasTypename = false; + // Inject __typename when: + // 1. This selection set is typed as an abstract type directly, OR + // 2. This is a concrete inline-fragment branch inside an abstract parent + // (parentIsAbstract && isObjectType), so the concrete type is + // identifiable only with __typename at runtime. + const needsTypename = + (thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType))) && + !selectionSet.selections.some(s => s.kind === Kind.FIELD && s.name.value === '__typename'); + let selectionsChanged = false; - const selections = selectionSet.selections; - const len = selections.length; + const augmentedSelections = selectionSet.selections.map(selection => { + if (selection.kind === Kind.FIELD) { + if (!selection.selectionSet) { + return selection; // Scalar or enum leaf. + } - let newSelections: Mutable | null = null; + const fieldDef = getFieldDef(schema, parentType, selection); + if (!fieldDef) return selection; - for (let i = 0; i < len; i++) { - const selection = selections[i]; - let newSelection = selection; + const fieldType = getNamedType(fieldDef.type); + if (!fieldType || !isCompositeType(fieldType)) return selection; - if (selection.kind === Kind.FIELD) { - if (mightNeedTypename && !hasTypename && selection.name.value === '__typename') { - hasTypename = true; - } + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + fieldType, + isAbstractType(fieldType), + schema, + ); - if (selection.selectionSet) { - const fieldDef = getFieldDef(schema, parentType, selection); - if (fieldDef) { - const fieldType = getNamedType(fieldDef.type); - if (fieldType && isCompositeType(fieldType)) { - const newChildSelectionSet = walkSelectionSet( - selection.selectionSet, - fieldType, - isAbstractType(fieldType), - schema, - ); - - if (newChildSelectionSet !== selection.selectionSet) { - newSelection = { ...selection, selectionSet: newChildSelectionSet } as FieldNode; - } - } - } + if (newSelectionSet !== selection.selectionSet) { + selectionsChanged = true; + return { + ...selection, + selectionSet: newSelectionSet, + } satisfies FieldNode; } - } else if (selection.kind === Kind.INLINE_FRAGMENT) { + + return selection; + } + + if (selection.kind === Kind.INLINE_FRAGMENT) { + // A typed fragment (`... on Foo`) narrows the parent type. + // An untyped fragment inherits the parent type. const branchType = selection.typeCondition ? schema.getType(selection.typeCondition.name.value) : parentType; - if (branchType && isCompositeType(branchType)) { - const newChildSelectionSet = walkSelectionSet( - selection.selectionSet, - branchType, - thisTypeIsAbstract || parentIsAbstract, - schema, - ); - - if (newChildSelectionSet !== selection.selectionSet) { - newSelection = { ...selection, selectionSet: newChildSelectionSet } as InlineFragmentNode; - } - } - } + if (!branchType || !isCompositeType(branchType)) return selection; + + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + branchType, + // Pass through whether the enclosing field was abstract, so that + // concrete branches (... on User inside a Node field) still get + // __typename injected into their own selection set. + thisTypeIsAbstract || parentIsAbstract, + schema, + ); - if (newSelection !== selection) { - if (!selectionsChanged) { + if (newSelectionSet !== selection.selectionSet) { selectionsChanged = true; - newSelections = selections.slice(0, i); + return { + ...selection, + selectionSet: newSelectionSet, + } satisfies InlineFragmentNode; } - } - if (selectionsChanged) { - newSelections!.push(newSelection); + return selection; } - } - const needsTypename = mightNeedTypename && !hasTypename; + // FRAGMENT_SPREAD — the definition is handled at the top-level definitions + // pass; the spread node itself carries no selectionSet. + return selection; + }); - if (!selectionsChanged && !needsTypename) { + if (!needsTypename && !selectionsChanged) { return selectionSet; } - // If we only need to append __typename but no children changed, we clone the array here. - const finalSelections = selectionsChanged ? newSelections! : selections.slice(); - - if (needsTypename) { - finalSelections.push(TYPENAME_FIELD); - } - return { ...selectionSet, - selections: finalSelections, + selections: needsTypename ? [...augmentedSelections, TYPENAME_FIELD] : augmentedSelections, }; } +/** + * Resolves the field definition for a given field node on a parent type, + * including the meta-fields __schema, __type, and __typename. + */ function getFieldDef(schema: GraphQLSchema, parentType: GraphQLCompositeType, field: FieldNode) { const name = field.name.value; - if (name === '__typename') return TypeNameMetaFieldDef; - - if (name === '__schema' || name === '__type') { - if (parentType === schema.getQueryType()) { - return name === '__schema' ? SchemaMetaFieldDef : TypeMetaFieldDef; - } + if (name === '__schema' && parentType === schema.getQueryType()) { + return SchemaMetaFieldDef; } - - if (isObjectType(parentType) || isInterfaceType(parentType)) { - return parentType.getFields()[name] ?? null; + if (name === '__type' && parentType === schema.getQueryType()) { + return TypeMetaFieldDef; + } + if (name === '__typename') { + return TypeNameMetaFieldDef; } - return null; + return 'getFields' in parentType ? (parentType.getFields()[name] ?? null) : null; } From 50eb5356c424bc1c17e7107993029630a0639450 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:04:50 -0700 Subject: [PATCH 58/62] Remove redundant checks and improve tests for add typenames --- .../src/add-typenames.ts | 75 ++---- .../tests/add-typenames.spec.ts | 247 +++++++++++++----- 2 files changed, 211 insertions(+), 111 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts index 56ef1f3cde2..6ed52467401 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts @@ -3,21 +3,18 @@ import { isAbstractType, isCompositeType, isObjectType, - Kind, - SchemaMetaFieldDef, - TypeMetaFieldDef, - TypeNameMetaFieldDef, type DocumentNode, type FieldNode, type GraphQLCompositeType, type GraphQLSchema, type InlineFragmentNode, + type Kind, // in order to support older graphql versions, do not rely on Kind for runtime type SelectionSetNode, } from 'graphql'; const TYPENAME_FIELD: FieldNode = { - kind: Kind.FIELD, - name: { kind: Kind.NAME, value: '__typename' }, + kind: 'Field' as Kind.FIELD, + name: { kind: 'Name' as Kind.NAME, value: '__typename' }, }; /** @@ -30,20 +27,16 @@ const TYPENAME_FIELD: FieldNode = { * document tree. */ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): DocumentNode { - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - let definitionsChanged = false; const augmentedDefinitions = document.definitions.map(def => { - if (def.kind === Kind.OPERATION_DEFINITION) { + if (def.kind === 'OperationDefinition') { const rootType = def.operation === 'query' - ? queryType + ? schema.getQueryType() : def.operation === 'mutation' - ? mutationType - : subscriptionType; + ? schema.getMutationType() + : schema.getSubscriptionType(); if (!rootType) return def; @@ -58,16 +51,14 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc return def; } - if (def.kind === Kind.FRAGMENT_DEFINITION) { + if (def.kind === 'FragmentDefinition') { const onType = schema.getType(def.typeCondition.name.value); - if (!onType || !isCompositeType(onType)) return def; + const typeIsAbstract = isAbstractType(onType); + // This check is equivalent to graphqljs' "isCompositeType", but it means we dont need + // to recheck for if the type is abstract. This is a minor efficiency thing. + if (!typeIsAbstract && !isObjectType(onType)) return def; - const newSelectionSet = walkSelectionSet( - def.selectionSet, - onType, - isAbstractType(onType), - schema, - ); + const newSelectionSet = walkSelectionSet(def.selectionSet, onType, typeIsAbstract, schema); if (newSelectionSet !== def.selectionSet) { definitionsChanged = true; return { @@ -107,29 +98,24 @@ function walkSelectionSet( schema: GraphQLSchema, ): SelectionSetNode { const thisTypeIsAbstract = isAbstractType(parentType); - - // Inject __typename when: - // 1. This selection set is typed as an abstract type directly, OR - // 2. This is a concrete inline-fragment branch inside an abstract parent - // (parentIsAbstract && isObjectType), so the concrete type is - // identifiable only with __typename at runtime. const needsTypename = - (thisTypeIsAbstract || (parentIsAbstract && isObjectType(parentType))) && - !selectionSet.selections.some(s => s.kind === Kind.FIELD && s.name.value === '__typename'); + (thisTypeIsAbstract || + (parentIsAbstract && isObjectType(parentType) && parentType.getInterfaces().length > 0)) && + !selectionSet.selections.some(s => s.kind === 'Field' && s.name.value === '__typename'); let selectionsChanged = false; const augmentedSelections = selectionSet.selections.map(selection => { - if (selection.kind === Kind.FIELD) { + if (selection.kind === 'Field') { if (!selection.selectionSet) { return selection; // Scalar or enum leaf. } - const fieldDef = getFieldDef(schema, parentType, selection); + const fieldDef = getFieldDef(parentType, selection); if (!fieldDef) return selection; const fieldType = getNamedType(fieldDef.type); - if (!fieldType || !isCompositeType(fieldType)) return selection; + if (!isCompositeType(fieldType)) return selection; const newSelectionSet = walkSelectionSet( selection.selectionSet, @@ -149,22 +135,19 @@ function walkSelectionSet( return selection; } - if (selection.kind === Kind.INLINE_FRAGMENT) { + if (selection.kind === 'InlineFragment') { // A typed fragment (`... on Foo`) narrows the parent type. // An untyped fragment inherits the parent type. const branchType = selection.typeCondition ? schema.getType(selection.typeCondition.name.value) : parentType; - if (!branchType || !isCompositeType(branchType)) return selection; + if (!isCompositeType(branchType)) return selection; const newSelectionSet = walkSelectionSet( selection.selectionSet, branchType, - // Pass through whether the enclosing field was abstract, so that - // concrete branches (... on User inside a Node field) still get - // __typename injected into their own selection set. - thisTypeIsAbstract || parentIsAbstract, + isAbstractType(branchType), schema, ); @@ -198,17 +181,11 @@ function walkSelectionSet( * Resolves the field definition for a given field node on a parent type, * including the meta-fields __schema, __type, and __typename. */ -function getFieldDef(schema: GraphQLSchema, parentType: GraphQLCompositeType, field: FieldNode) { +function getFieldDef(parentType: GraphQLCompositeType, field: FieldNode) { const name = field.name.value; - - if (name === '__schema' && parentType === schema.getQueryType()) { - return SchemaMetaFieldDef; - } - if (name === '__type' && parentType === schema.getQueryType()) { - return TypeMetaFieldDef; - } - if (name === '__typename') { - return TypeNameMetaFieldDef; + // ignore internal fields like __schema, __type, and __typename + if (name.startsWith('__')) { + return null; } return 'getFields' in parentType ? (parentType.getFields()[name] ?? null) : null; diff --git a/packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts b/packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts index cf32a682071..1539c191376 100644 --- a/packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts +++ b/packages/libraries/gateway-plugin-console-sdk/tests/add-typenames.spec.ts @@ -1,4 +1,4 @@ -import { buildSchema, parse, print } from 'graphql'; +import { buildSchema, parse as gql, print } from 'graphql'; import { addTypenames } from '../src/add-typenames.js'; // --------------------------------------------------------------------------- @@ -75,23 +75,10 @@ const schema = buildSchema(` } `); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function gql(src: string) { - return parse(src); -} - function typenameCount(doc: ReturnType): number { return (print(doc).match(/__typename/g) ?? []).length; } -// --------------------------------------------------------------------------- -// Concrete object types with no abstract ancestry in the query -// — __typename should NOT be added -// --------------------------------------------------------------------------- - describe('concrete object types (not inside an abstract field)', () => { it('does not add __typename when the root field returns a concrete type', () => { const doc = gql(` @@ -106,7 +93,6 @@ describe('concrete object types (not inside an abstract field)', () => { }); it('does not add __typename for nested concrete object fields', () => { - // User → Address: both concrete with no abstract involvement. const doc = gql(` query { user(id: "1") { @@ -121,10 +107,6 @@ describe('concrete object types (not inside an abstract field)', () => { }); }); -// --------------------------------------------------------------------------- -// Interface types — __typename SHOULD be added on the abstract field itself -// --------------------------------------------------------------------------- - describe('interface types', () => { it('adds __typename when a field returns an interface', () => { const doc = gql(` @@ -134,7 +116,16 @@ describe('interface types', () => { } } `); - expect(typenameCount(addTypenames(doc, schema))).toBe(1); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + id + __typename + } + } + `); + expect(typenameCount(result)).toBe(1); }); it('adds __typename for a list field returning an interface', () => { @@ -145,11 +136,19 @@ describe('interface types', () => { } } `); - expect(typenameCount(addTypenames(doc, schema))).toBe(1); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + animals { + name + __typename + } + } + `); + expect(typenameCount(result)).toBe(1); }); it('adds __typename on a nested interface field inside a concrete type', () => { - // user → User (concrete, no __typename); friends → [Node] (interface, add __typename) const doc = gql(` query { user(id: "1") { @@ -159,11 +158,21 @@ describe('interface types', () => { } } `); - expect(typenameCount(addTypenames(doc, schema))).toBe(1); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + user(id: "1") { + friends { + id + __typename + } + } + } + `); + expect(typenameCount(result)).toBe(1); }); it('adds __typename on a self-referencing interface field', () => { - // animals → Animal (interface); offspring → Animal (interface) const doc = gql(` query { animals { @@ -174,8 +183,20 @@ describe('interface types', () => { } } `); - // 2: one per abstract selection set - expect(typenameCount(addTypenames(doc, schema))).toBe(2); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + animals { + name + offspring { + name + __typename + } + __typename + } + } + `); + expect(typenameCount(result)).toBe(2); }); it('does not duplicate __typename when already present on an interface field', () => { @@ -187,14 +208,19 @@ describe('interface types', () => { } } `); - expect(typenameCount(addTypenames(doc, schema))).toBe(1); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + __typename + id + } + } + `); + expect(typenameCount(result)).toBe(1); }); }); -// --------------------------------------------------------------------------- -// Union types — __typename SHOULD be added on the abstract field itself -// --------------------------------------------------------------------------- - describe('union types', () => { it('adds __typename on the union field selection set', () => { const doc = gql(` @@ -210,20 +236,25 @@ describe('union types', () => { } `); const result = addTypenames(doc, schema); - const printed = print(result); - // search (union) → __typename on the outer set - expect(printed).toContain('__typename'); + expect(print(result)).toMatchInlineSnapshot(` + { + search(term: "foo") { + ... on User { + name + } + ... on Post { + title + } + __typename + } + } + `); + expect(typenameCount(result)).toBe(1); }); }); -// --------------------------------------------------------------------------- -// Concrete inline-fragment branches of abstract types -// — __typename SHOULD be added inside the branch -// --------------------------------------------------------------------------- - describe('concrete inline-fragment branches of abstract fields', () => { - it('adds __typename inside a concrete inline fragment on an interface field', () => { - // node → Node (interface): outer set + ... on User branch both get __typename + it('adds __typename on the abstract type implementing an inline fragment', () => { const doc = gql(` query { node(id: "1") { @@ -234,8 +265,17 @@ describe('concrete inline-fragment branches of abstract fields', () => { } `); const result = addTypenames(doc, schema); - // 2: one on the node (Node interface) set, one inside the ... on User branch - expect(typenameCount(result)).toBe(2); + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + ... on User { + name + } + __typename + } + } + `); + expect(typenameCount(result)).toBe(1); }); it('adds __typename inside each concrete branch of a union', () => { @@ -252,8 +292,20 @@ describe('concrete inline-fragment branches of abstract fields', () => { } `); const result = addTypenames(doc, schema); - // search (union) + ... on User + ... on Post = 3 - expect(typenameCount(result)).toBe(3); + expect(print(result)).toMatchInlineSnapshot(` + { + search(term: "foo") { + ... on User { + name + } + ... on Post { + title + } + __typename + } + } + `); + expect(typenameCount(result)).toBe(1); }); it('does not duplicate __typename in a concrete branch that already has it', () => { @@ -268,13 +320,21 @@ describe('concrete inline-fragment branches of abstract fields', () => { } `); const result = addTypenames(doc, schema); - // node set + User branch (not duplicated) = 2 + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + ... on User { + __typename + name + } + __typename + } + } + `); expect(typenameCount(result)).toBe(2); }); it('adds __typename in a concrete branch and recurses into its abstract sub-fields', () => { - // pets → [Animal] (interface); ... on Dog (concrete branch) gets __typename; - // Dog.offspring → [Animal] (interface) also gets __typename. const doc = gql(` query { user(id: "1") { @@ -290,12 +350,26 @@ describe('concrete inline-fragment branches of abstract fields', () => { } `); const result = addTypenames(doc, schema); - // pets (Animal interface) + ... on Dog branch + offspring (Animal interface) = 3 - expect(typenameCount(result)).toBe(3); + expect(print(result)).toMatchInlineSnapshot(` + { + user(id: "1") { + pets { + ... on Dog { + breed + offspring { + name + __typename + } + } + __typename + } + } + } + `); + expect(typenameCount(result)).toBe(2); }); it('adds __typename in an untyped inline fragment inside an abstract field', () => { - // An anonymous `... { }` inherits the parent abstract type. const doc = gql(` query { node(id: "1") { @@ -306,7 +380,17 @@ describe('concrete inline-fragment branches of abstract fields', () => { } `); const result = addTypenames(doc, schema); - // node (Node interface) → __typename; anonymous fragment inherits Node → also abstract → __typename + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + ... { + id + __typename + } + __typename + } + } + `); expect(typenameCount(result)).toBe(2); }); }); @@ -331,8 +415,19 @@ describe('mixed fields', () => { } `); const result = addTypenames(doc, schema); - // address is concrete with no abstract parent → no __typename - // pets is abstract → __typename + expect(print(result)).toMatchInlineSnapshot(` + { + user(id: "1") { + address { + city + } + pets { + name + __typename + } + } + } + `); expect(typenameCount(result)).toBe(1); }); }); @@ -355,7 +450,19 @@ describe('named fragments', () => { } `); const result = addTypenames(doc, schema); - // node field (Node) + NodeFields fragment body (also on Node) = 2 + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + ...NodeFields + __typename + } + } + + fragment NodeFields on Node { + id + __typename + } + `); expect(typenameCount(result)).toBe(2); }); @@ -372,11 +479,17 @@ describe('named fragments', () => { name } `); - // user is concrete, UserFields is on User (concrete) → no __typename anywhere expect(typenameCount(addTypenames(doc, schema))).toBe(0); }); - it('does not add __typename to the fragment spread node itself', () => { + /** + * @NOTE This may be unnecessary, but it's not harmful. The typename is requested on an abstract + * field always, regardless of the selection set inside. This simplifies the logic. + * The __typename on the fragment could be avoided, but it helps ensure that if + * federation splits the request due to entity types, that that fragment still + * will receive the typename. + */ + it('adds __typename to the fragment spread node and the parent field', () => { const doc = gql(` query { node(id: "1") { @@ -388,14 +501,24 @@ describe('named fragments', () => { id } `); - expect(print(addTypenames(doc, schema))).toContain('...NodeFields'); + const result = addTypenames(doc, schema); + expect(print(result)).toMatchInlineSnapshot(` + { + node(id: "1") { + ...NodeFields + __typename + } + } + + fragment NodeFields on Node { + id + __typename + } + `); + expect(typenameCount(result)).toBe(2); }); }); -// --------------------------------------------------------------------------- -// Introspection fields -// --------------------------------------------------------------------------- - describe('introspection fields', () => { it('does not add __typename inside __schema', () => { const doc = gql(` From 6575cf9d726df9e80f31e82dfc0106a1ceabe841 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:03:21 -0700 Subject: [PATCH 59/62] avoid memory allocating when necessary --- .../src/add-typenames.ts | 192 +++++++++--------- 1 file changed, 99 insertions(+), 93 deletions(-) diff --git a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts index 6ed52467401..871ebb9912e 100644 --- a/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts +++ b/packages/libraries/gateway-plugin-console-sdk/src/add-typenames.ts @@ -1,8 +1,10 @@ import { + DefinitionNode, getNamedType, isAbstractType, isCompositeType, isObjectType, + SelectionNode, type DocumentNode, type FieldNode, type GraphQLCompositeType, @@ -28,8 +30,12 @@ const TYPENAME_FIELD: FieldNode = { */ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): DocumentNode { let definitionsChanged = false; + let newDefinitions: DefinitionNode[] | null = null; + + for (let i = 0; i < document.definitions.length; i++) { + const def = document.definitions[i]; + let newDef = def; - const augmentedDefinitions = document.definitions.map(def => { if (def.kind === 'OperationDefinition') { const rootType = def.operation === 'query' @@ -38,39 +44,39 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc ? schema.getMutationType() : schema.getSubscriptionType(); - if (!rootType) return def; + if (rootType) { + const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); + if (newSelectionSet !== def.selectionSet) { + newDef = { ...def, selectionSet: newSelectionSet }; + } + } + } else if (def.kind === 'FragmentDefinition') { + const onType = schema.getType(def.typeCondition.name.value); - const newSelectionSet = walkSelectionSet(def.selectionSet, rootType, false, schema); - if (newSelectionSet !== def.selectionSet) { - definitionsChanged = true; - return { - ...def, - selectionSet: newSelectionSet, - }; + if (isCompositeType(onType)) { + const newSelectionSet = walkSelectionSet( + def.selectionSet, + onType, + isAbstractType(onType), + schema, + ); + if (newSelectionSet !== def.selectionSet) { + newDef = { ...def, selectionSet: newSelectionSet }; + } } - return def; } - if (def.kind === 'FragmentDefinition') { - const onType = schema.getType(def.typeCondition.name.value); - const typeIsAbstract = isAbstractType(onType); - // This check is equivalent to graphqljs' "isCompositeType", but it means we dont need - // to recheck for if the type is abstract. This is a minor efficiency thing. - if (!typeIsAbstract && !isObjectType(onType)) return def; - - const newSelectionSet = walkSelectionSet(def.selectionSet, onType, typeIsAbstract, schema); - if (newSelectionSet !== def.selectionSet) { + if (newDef !== def) { + if (!definitionsChanged) { definitionsChanged = true; - return { - ...def, - selectionSet: newSelectionSet, - }; + newDefinitions = document.definitions.slice(0, i); } - return def; } - return def; - }); + if (definitionsChanged && newDefinitions) { + newDefinitions.push(newDef); + } + } if (!definitionsChanged) { return document; @@ -78,19 +84,10 @@ export function addTypenames(document: DocumentNode, schema: GraphQLSchema): Doc return { ...document, - definitions: augmentedDefinitions, + definitions: newDefinitions!, }; } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * @param parentIsAbstract - true when the immediately enclosing field (or - * fragment condition) resolved to an abstract type. This is propagated into - * concrete inline-fragment branches so they also receive __typename. - */ function walkSelectionSet( selectionSet: SelectionSetNode, parentType: GraphQLCompositeType, @@ -98,89 +95,98 @@ function walkSelectionSet( schema: GraphQLSchema, ): SelectionSetNode { const thisTypeIsAbstract = isAbstractType(parentType); - const needsTypename = - (thisTypeIsAbstract || - (parentIsAbstract && isObjectType(parentType) && parentType.getInterfaces().length > 0)) && - !selectionSet.selections.some(s => s.kind === 'Field' && s.name.value === '__typename'); + const checkTypename = + thisTypeIsAbstract || + (parentIsAbstract && isObjectType(parentType) && parentType.getInterfaces().length > 0); let selectionsChanged = false; + let newSelections: SelectionNode[] | null = null; + let hasTypename = false; - const augmentedSelections = selectionSet.selections.map(selection => { - if (selection.kind === 'Field') { - if (!selection.selectionSet) { - return selection; // Scalar or enum leaf. - } - - const fieldDef = getFieldDef(parentType, selection); - if (!fieldDef) return selection; - - const fieldType = getNamedType(fieldDef.type); - if (!isCompositeType(fieldType)) return selection; + const selections = selectionSet.selections; - const newSelectionSet = walkSelectionSet( - selection.selectionSet, - fieldType, - isAbstractType(fieldType), - schema, - ); + for (let i = 0; i < selections.length; i++) { + const selection = selections[i]; + let newSelection = selection; - if (newSelectionSet !== selection.selectionSet) { - selectionsChanged = true; - return { - ...selection, - selectionSet: newSelectionSet, - } satisfies FieldNode; + if (selection.kind === 'Field') { + if (checkTypename && selection.name.value === '__typename') { + hasTypename = true; } - return selection; - } - - if (selection.kind === 'InlineFragment') { - // A typed fragment (`... on Foo`) narrows the parent type. - // An untyped fragment inherits the parent type. + if (selection.selectionSet) { + const fieldDef = getFieldDef(parentType, selection); + if (fieldDef) { + const fieldType = getNamedType(fieldDef.type); + if (isCompositeType(fieldType)) { + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + fieldType, + isAbstractType(fieldType), + schema, + ); + + if (newSelectionSet !== selection.selectionSet) { + newSelection = { + ...selection, + selectionSet: newSelectionSet, + } satisfies FieldNode; + } + } + } + } + } else if (selection.kind === 'InlineFragment') { const branchType = selection.typeCondition ? schema.getType(selection.typeCondition.name.value) : parentType; - if (!isCompositeType(branchType)) return selection; - - const newSelectionSet = walkSelectionSet( - selection.selectionSet, - branchType, - isAbstractType(branchType), - schema, - ); + if (isCompositeType(branchType)) { + const newSelectionSet = walkSelectionSet( + selection.selectionSet, + branchType, + isAbstractType(branchType), + schema, + ); + + if (newSelectionSet !== selection.selectionSet) { + newSelection = { + ...selection, + selectionSet: newSelectionSet, + } satisfies InlineFragmentNode; + } + } + } - if (newSelectionSet !== selection.selectionSet) { + if (newSelection !== selection) { + if (!selectionsChanged) { selectionsChanged = true; - return { - ...selection, - selectionSet: newSelectionSet, - } satisfies InlineFragmentNode; + // This logic avoids copying the selection list before it's absolutely necessary. + // Therefore, no additional memory will be wasted copying when iterating over + // concrete, non-implementing object types. + newSelections = selections.slice(0, i); } - - return selection; } - // FRAGMENT_SPREAD — the definition is handled at the top-level definitions - // pass; the spread node itself carries no selectionSet. - return selection; - }); + if (selectionsChanged && newSelections) { + newSelections.push(newSelection); + } + } + const needsTypename = checkTypename && !hasTypename; if (!needsTypename && !selectionsChanged) { return selectionSet; } + const finalSelections = selectionsChanged && newSelections ? newSelections : [...selections]; + if (needsTypename) { + finalSelections.push(TYPENAME_FIELD); + } return { ...selectionSet, - selections: needsTypename ? [...augmentedSelections, TYPENAME_FIELD] : augmentedSelections, + selections: finalSelections, }; } -/** - * Resolves the field definition for a given field node on a parent type, - * including the meta-fields __schema, __type, and __typename. - */ function getFieldDef(parentType: GraphQLCompositeType, field: FieldNode) { const name = field.name.value; // ignore internal fields like __schema, __type, and __typename From 4131b256f40e5a30cd7f92113bec2b3c87d8aea4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:41:20 -0700 Subject: [PATCH 60/62] Attempt performance improvements to extract-coordinates --- .../client/subrequests/extract-coordinates.ts | 200 ++++++++---------- 1 file changed, 86 insertions(+), 114 deletions(-) diff --git a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts index 0e3bcb38841..0f10baa515b 100644 --- a/packages/libraries/core/src/client/subrequests/extract-coordinates.ts +++ b/packages/libraries/core/src/client/subrequests/extract-coordinates.ts @@ -16,6 +16,20 @@ import { type SelectionNode, } from 'graphql'; +/** + * Consolidates static request-scoped variables to reduce + * memory allocation overhead on deep stack frames. + */ +type TraversalContext = { + counts: Map; + getType: (name: string) => GraphQLNamedType | undefined; + doesTypeMatch: (runtime: string, condition: string) => boolean; + fragments: Map; + fieldMetaCache: Map; + contextValue?: any; + infoValue?: GraphQLResolveInfo; +}; + /** * Extracts true schema coordinates and counts their execution volume, * including resolved types, aliases, unions, interfaces, and scalars. @@ -28,44 +42,51 @@ export function extractCoordinates( contextValue?: any, infoValue?: GraphQLResolveInfo, ): Record { - const counts: Record = Object.create(null); + // Using Maps internally for optimized high-frequency writes + const counts = new Map(); + const fragments = new Map(); let operation: OperationDefinitionNode | undefined; - const fragments: Record = Object.create(null); for (let i = 0; i < document.definitions.length; i++) { const def = document.definitions[i]; if (def.kind === 'OperationDefinition' && !operation) { operation = def; } else if (def.kind === 'FragmentDefinition') { - fragments[def.name.value] = def; + fragments.set(def.name.value, def); } } - if (!operation || !resultData) return counts; + // Convert empty Map to Record to maintain return signature + if (!operation || !resultData) return {}; let rootType: GraphQLObjectType | undefined | null; if (operation.operation === 'query') rootType = schema.getQueryType(); else if (operation.operation === 'mutation') rootType = schema.getMutationType(); else if (operation.operation === 'subscription') rootType = schema.getSubscriptionType(); - if (!rootType) return counts; + if (!rootType) return {}; - const typeCache: Record = Object.create(null); + const typeCache = new Map(); const getType = (name: string): GraphQLNamedType | undefined => { - if (!typeCache[name]) { - const type = schema.getType(name); - if (type) typeCache[name] = type; + let type = typeCache.get(name); + if (!type) { + const schemaType = schema.getType(name); + if (schemaType) { + typeCache.set(name, schemaType); + type = schemaType; + } } - return typeCache[name]; + return type; }; - const matchCache: Record = Object.create(null); + const matchCache = new Map(); const doesTypeMatch = (runtimeTypeName: string, typeConditionName: string): boolean => { if (runtimeTypeName === typeConditionName) return true; const cacheKey = runtimeTypeName + '|' + typeConditionName; - if (matchCache[cacheKey] !== undefined) return matchCache[cacheKey]; + const cached = matchCache.get(cacheKey); + if (cached !== undefined) return cached; const runtimeType = getType(runtimeTypeName); const conditionType = getType(typeConditionName); @@ -79,35 +100,36 @@ export function extractCoordinates( } } - matchCache[cacheKey] = isMatch; + matchCache.set(cacheKey, isMatch); return isMatch; }; - walkNode( - resultData, - operation.selectionSet.selections, - rootType, + const ctx: TraversalContext = { counts, getType, doesTypeMatch, fragments, + fieldMetaCache: new Map(), contextValue, infoValue, - ); + }; + + walkNode(resultData, operation.selectionSet.selections, rootType, ctx); - return counts; + // Convert internal Map to a plain object for the public API boundary + const resultRecord: Record = Object.create(null); + for (const [key, value] of counts.entries()) { + resultRecord[key] = value; + } + + return resultRecord; } function walkNode( data: any, selections: readonly SelectionNode[], parentType: GraphQLType, - counts: Record, - getType: (name: string) => GraphQLNamedType | undefined, - doesTypeMatch: (runtime: string, condition: string) => boolean, - fragments: Record, - contextValue?: any, - infoValue?: GraphQLResolveInfo, + ctx: TraversalContext, ) { if (data === null || typeof data !== 'object') return; @@ -120,48 +142,21 @@ function walkNode( for (let i = 0; i < data.length; i++) { const item = data[i]; if (item !== null && typeof item === 'object') { - walkObject( - item, - selections, - namedType, - counts, - getType, - doesTypeMatch, - fragments, - false, - contextValue, - infoValue, - ); + walkObject(item, selections, namedType, false, ctx); } } return; } - walkObject( - data, - selections, - namedType, - counts, - getType, - doesTypeMatch, - fragments, - false, - contextValue, - infoValue, - ); + walkObject(data, selections, namedType, false, ctx); } function walkObject( data: any, selections: readonly SelectionNode[], namedType: GraphQLNamedType, - counts: Record, - getType: (name: string) => GraphQLNamedType | undefined, - doesTypeMatch: (runtime: string, condition: string) => boolean, - fragments: Record, isFragmentRecurse: boolean, - contextValue?: any, - infoValue?: GraphQLResolveInfo, + ctx: TraversalContext, ) { const namedTypeName = namedType.name; let runtimeTypeName = data.__typename; @@ -169,13 +164,9 @@ function walkObject( // Use resolveType for abstract types if __typename is missing from the payload if (!runtimeTypeName && isAbstractType) { - /** - * Note that this should be a fallback for an extreme edge case. Because it's very unreliable. - * Abstract types cannot be determined in the gateway to be of a specific other type because the resolveType function - * won't have been implemented. However, this may solve the case in a monorepo. - */ runtimeTypeName = - (infoValue && namedType.resolveType?.(data, contextValue, infoValue, namedType)) ?? + (ctx.infoValue && + namedType.resolveType?.(data, ctx.contextValue, ctx.infoValue, namedType)) ?? namedTypeName; } @@ -183,12 +174,12 @@ function walkObject( /** Track the abstract type as having been used */ if (runtimeTypeName !== namedTypeName && isAbstractType) { - counts[namedTypeName] = (counts[namedTypeName] || 0) + 1; + ctx.counts.set(namedTypeName, (ctx.counts.get(namedTypeName) || 0) + 1); } if (!isFragmentRecurse) { // This accurately registers the resolved implementation to the counts map - counts[runtimeTypeName] = (counts[runtimeTypeName] || 0) + 1; + ctx.counts.set(runtimeTypeName, (ctx.counts.get(runtimeTypeName) || 0) + 1); } let fields: GraphQLFieldMap | GraphQLInputFieldMap | null = null; @@ -205,41 +196,44 @@ function walkObject( if (val !== undefined) { const coordinate = namedTypeName + '.' + realFieldName; - counts[coordinate] = (counts[coordinate] || 0) + 1; + ctx.counts.set(coordinate, (ctx.counts.get(coordinate) || 0) + 1); if (val !== null) { - if (!fields) { - fields = 'getFields' in namedType ? namedType.getFields() : {}; + // Attempt to pull the resolved schema definitions from our short-lived cache + let meta = ctx.fieldMetaCache.get(coordinate); + + if (!meta) { + if (!fields) { + fields = 'getFields' in namedType ? namedType.getFields() : {}; + } + const fieldDef = fields[realFieldName]; + if (fieldDef) { + meta = { + type: fieldDef.type, + leafTypeName: selection.selectionSet + ? undefined + : getNamedType(fieldDef.type)?.name, + }; + ctx.fieldMetaCache.set(coordinate, meta); + } } - const fieldDef = fields[realFieldName]; - if (fieldDef) { + if (meta) { if (selection.selectionSet) { - walkNode( - val, - selection.selectionSet.selections, - fieldDef.type, - counts, - getType, - doesTypeMatch, - fragments, - contextValue, - infoValue, - ); - } else { - // Feature: Extract and count Leaf Types (Scalars / Enums) - const leafTypeName = getNamedType(fieldDef.type)?.name; - + walkNode(val, selection.selectionSet.selections, meta.type, ctx); + } else if (meta.leafTypeName) { + // Fast path for arrays of Leaf Types (Scalars / Enums) + const leafType = meta.leafTypeName; if (Array.isArray(val)) { let leafCount = 0; for (let j = 0; j < val.length; j++) { if (val[j] !== null) leafCount++; } if (leafCount > 0) { - counts[leafTypeName] = (counts[leafTypeName] || 0) + leafCount; + ctx.counts.set(leafType, (ctx.counts.get(leafType) || 0) + leafCount); } } else { - counts[leafTypeName] = (counts[leafTypeName] || 0) + 1; + ctx.counts.set(leafType, (ctx.counts.get(leafType) || 0) + 1); } } } @@ -251,29 +245,18 @@ function walkObject( let nextType: GraphQLNamedType = namedType; if (typeConditionName) { - matchesType = doesTypeMatch(runtimeTypeName, typeConditionName); + matchesType = ctx.doesTypeMatch(runtimeTypeName, typeConditionName); if (matchesType) { - nextType = getType(typeConditionName) || namedType; + nextType = ctx.getType(typeConditionName) || namedType; } } if (matchesType && selection.selectionSet) { - walkObject( - data, - selection.selectionSet.selections, - nextType, - counts, - getType, - doesTypeMatch, - fragments, - true, - contextValue, - infoValue, - ); + walkObject(data, selection.selectionSet.selections, nextType, true, ctx); } } else if (selection.kind === 'FragmentSpread') { const fragmentName = selection.name.value; - const fragmentDef = fragments[fragmentName]; + const fragmentDef = ctx.fragments.get(fragmentName); if (fragmentDef) { const typeConditionName = fragmentDef.typeCondition.name.value; @@ -281,25 +264,14 @@ function walkObject( let nextType: GraphQLNamedType = namedType; if (typeConditionName) { - matchesType = doesTypeMatch(runtimeTypeName, typeConditionName); + matchesType = ctx.doesTypeMatch(runtimeTypeName, typeConditionName); if (matchesType) { - nextType = getType(typeConditionName) || namedType; + nextType = ctx.getType(typeConditionName) || namedType; } } if (matchesType && fragmentDef.selectionSet) { - walkObject( - data, - fragmentDef.selectionSet.selections, - nextType, - counts, - getType, - doesTypeMatch, - fragments, - true, - contextValue, - infoValue, - ); + walkObject(data, fragmentDef.selectionSet.selections, nextType, true, ctx); } } } From 2effd2cbd437caa881419c58202bc25bcdc740f8 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:43:32 -0700 Subject: [PATCH 61/62] remove comments --- .../core/src/client/subrequests/path-to-coordinate.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts b/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts index 7f3ce16325d..5cc75154cb0 100644 --- a/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts +++ b/packages/libraries/core/src/client/subrequests/path-to-coordinate.ts @@ -11,7 +11,6 @@ export function pathToCoordinate( errorPath: readonly (string | number)[], operationType: 'mutation' | 'subscription' | 'query' = 'query', ): string | undefined { - // 1. Start at the root operation type let currentType; if (operationType === 'mutation') currentType = schema.getMutationType(); else if (operationType === 'subscription') currentType = schema.getSubscriptionType(); @@ -20,13 +19,10 @@ export function pathToCoordinate( let coordinate = null; for (const segment of errorPath) { - // 2. Skip array indices entirely (they don't change the underlying type) + // Skip array indices entirely (they don't change the underlying type) if (typeof segment === 'number') continue; - - // 3. Unwrap Non-Null (!) and List ([]) types to get the base Object/Interface currentType = getNamedType(currentType); - // 4. Ensure the current type has fields if (isObjectType(currentType) || isInterfaceType(currentType)) { const fields: GraphQLFieldMap = currentType.getFields(); const field = fields[segment]; @@ -38,10 +34,7 @@ export function pathToCoordinate( return; } - // Update the coordinate to the current Type.field coordinate = `${currentType.name}.${field.name}`; - - // Move deeper into the tree for the next iteration currentType = field.type; } } From 3b03aa0a1cfd7c59e95bf2849f17005ca7b6817e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:12:08 -0700 Subject: [PATCH 62/62] Avoid converting to set then back to array for exclusions --- packages/libraries/core/src/client/usage.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/libraries/core/src/client/usage.ts b/packages/libraries/core/src/client/usage.ts index 42e212bd1ef..abfc40a46ca 100644 --- a/packages/libraries/core/src/client/usage.ts +++ b/packages/libraries/core/src/client/usage.ts @@ -85,7 +85,7 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl const selfHostingOptions = pluginOptions.selfHosting; const logger = pluginOptions.logger.child({ module: 'hive-usage' }); const collector = memo(createCollector, arg => arg.schema); - const excludeSet = new Set(options.exclude ?? []); + const exclusions = options.exclude ?? []; /** Access tokens using the `hvo1/` require a target. */ if (!options.target && !isLegacyAccessToken(pluginOptions.token)) { @@ -254,8 +254,8 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ) as OperationDefinitionNode; providedOperationName = args.args.operationName || rootOperation.name?.value; const operationName = providedOperationName || 'anonymous'; - // Check if operationName is a match with any string or regex in excludeSet - const isMatch = Array.from(excludeSet).some(excludingValue => + // Check if operationName is a match with any string or regex in exclusions + const isMatch = exclusions.some(excludingValue => excludingValue instanceof RegExp ? excludingValue.test(operationName) : operationName === excludingValue, @@ -385,8 +385,8 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl ) as OperationDefinitionNode; const providedOperationName = args.operationName || rootOperation.name?.value; const operationName = providedOperationName || 'anonymous'; - // Check if operationName is a match with any string or regex in excludeSet - const isMatch = Array.from(excludeSet).some(excludingValue => + // Check if operationName is a match with any string or regex in exclusions + const isMatch = exclusions.some(excludingValue => excludingValue instanceof RegExp ? excludingValue.test(operationName) : operationName === excludingValue,