Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/operation-level-sample-rates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@graphql-hive/core': minor
---

Add operation-level sample rates to the usage plugin via the new `sampleRates` option.

Each rule matches operations by exact `name` or by `regex` (tested against the operation name) and applies its own `sampleRate`. The first matching rule wins; operations that don't match any rule fall back to the global `sampleRate` (default `1.0`). This lets you sample known high-volume operations aggressively while keeping 100% visibility into low-volume operations, instead of choosing a single global rate that either overpays on quota or misses rare operations.

`exclude` still takes precedence (excluded operations are never reported), and `sampler`, when provided, continues to override both `sampleRate` and `sampleRates`.

```ts
usage: {
sampleRate: 1.0, // fallback for unmatched operations
sampleRates: [
{ name: 'SampledQuery', sampleRate: 0.1 },
{ regex: /^HighVolume/, sampleRate: 0.1 },
],
}
```
105 changes: 105 additions & 0 deletions packages/libraries/core/demo/operation-sample-rates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Demo / reproduction for operation-level sample rates.
*
* Scenario from the feature request: a system mixes a few very high-volume
* operations with many low-volume ones. Global sampling forces a bad trade-off:
* - sample everything at 100% -> the high-volume ops blow the usage quota
* - sample everything at e.g. 5% -> the low-volume ops are missed entirely
*
* Operation-level sample rates let us sample the known high-volume operations
* aggressively while keeping 100% visibility into low-volume operations.
*
* Run from the repo root:
* pnpm --filter @graphql-hive/core exec tsx demo/operation-sample-rates.ts
* or from packages/libraries/core:
* pnpm exec tsx demo/operation-sample-rates.ts
*/
import { parse } from 'graphql';
import { operationSampling, randomSampling } from '../src/client/sampling.js';
import type { SamplingContext } from '../src/client/types.js';

const document = parse(/* GraphQL */ `
query {
__typename
}
`);

function ctx(operationName: string): SamplingContext {
return { operationName, document, variableValues: null, contextValue: undefined };
}

// A simulated traffic mix: operation name -> number of executions in the window.
const traffic: Array<{ operationName: string; volume: number }> = [
{ operationName: 'HighVolumeFeed', volume: 500_000 },
{ operationName: 'HighVolumePing', volume: 250_000 },
{ operationName: 'CheckoutMutation', volume: 1_200 },
{ operationName: 'AdminAuditReport', volume: 40 },
{ operationName: 'RareMigrationQuery', volume: 8 },
];

const totalOps = traffic.reduce((sum, t) => sum + t.volume, 0);

function simulate(shouldInclude: (c: SamplingContext) => boolean) {
const sent = new Map<string, number>();
for (const { operationName, volume } of traffic) {
let count = 0;
for (let i = 0; i < volume; i++) {
if (shouldInclude(ctx(operationName))) count++;
}
sent.set(operationName, count);
}
return sent;
}

function pct(part: number, whole: number) {
return whole === 0 ? '0%' : `${((part / whole) * 100).toFixed(1)}%`;
}

function printResult(title: string, sent: Map<string, number>) {
console.log(`\n=== ${title} ===`);
let totalSent = 0;
for (const { operationName, volume } of traffic) {
const s = sent.get(operationName) ?? 0;
totalSent += s;
const visibility = s > 0 ? 'visible' : 'MISSED';
console.log(
` ${operationName.padEnd(20)} volume=${String(volume).padStart(7)} ` +
`sent=${String(s).padStart(7)} (${pct(s, volume).padStart(6)}) ${visibility}`,
);
}
console.log(
` ${'TOTAL'.padEnd(20)} volume=${String(totalOps).padStart(7)} ` +
`sent=${String(totalSent).padStart(7)} (${pct(totalSent, totalOps)} of quota)`,
);
return totalSent;
}

// --- Strategy A: global 100% sampling (full visibility, full quota cost) -----
const globalFull = printResult('Global sampleRate = 1.0 (overpay)', simulate(randomSampling(1.0)));

// --- Strategy B: global 5% sampling (cheap, but loses low-volume ops) --------
const globalLow = printResult(
'Global sampleRate = 0.05 (low-volume ops missed)',
simulate(randomSampling(0.05)),
);

// --- Strategy C: operation-level sample rates (the new feature) --------------
// Sample the high-volume ops at 1%, keep everything else at 100% via fallback.
const perOp = printResult(
'Operation-level: /^HighVolume/ -> 1%, fallback 100%',
simulate(
operationSampling({
rates: [{ regex: /^HighVolume/, sampleRate: 0.01 }],
fallbackSampleRate: 1.0,
}),
),
);

console.log('\n=== Summary ===');
console.log(` Global 100%: ${globalFull} ops billed (${pct(globalFull, totalOps)})`);
console.log(` Global 5%: ${globalLow} ops billed (${pct(globalLow, totalOps)})`);
console.log(` Operation-level: ${perOp} ops billed (${pct(perOp, totalOps)})`);
console.log(
'\n -> Operation-level sampling slashes billed volume like the cheap global rate,',
);
console.log(' while still reporting 100% of the low-volume operations.');
57 changes: 56 additions & 1 deletion packages/libraries/core/src/client/sampling.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SamplingContext } from './types.js';
import type { OperationSampleRate, SamplingContext } from './types.js';

export function randomSampling(sampleRate: number) {
if (sampleRate > 1 || sampleRate < 0) {
Expand All @@ -10,6 +10,61 @@ export function randomSampling(sampleRate: number) {
};
}

/**
* Builds a sampling function that applies operation-level sample rates.
*
* Each rule matches an operation by exact `name` or by `regex` (tested against the
* operation name). The first matching rule wins and its `sampleRate` is used.
* Operations that don't match any rule fall back to `fallbackSampleRate`.
*
* Rules are validated eagerly so misconfiguration fails fast at setup time.
*/
export function operationSampling(config: {
rates: OperationSampleRate[];
fallbackSampleRate: number;
}) {
if (config.fallbackSampleRate > 1 || config.fallbackSampleRate < 0) {
throw new Error(
`Expected usage.sampleRate to be 0 <= x <= 1, received ${config.fallbackSampleRate}`,
);
}

const rules = config.rates.map(rate => {
const { name, regex, sampleRate } = rate;
const hasName = typeof name === 'string';
const hasRegex = regex instanceof RegExp;

if (hasName === hasRegex) {
throw new Error(
'Expected usage.sampleRates entry to define exactly one of "name" or "regex".',
);
}

if (sampleRate > 1 || sampleRate < 0) {
throw new Error(
`Expected usage.sampleRates sampleRate to be 0 <= x <= 1, received ${sampleRate}`,
);
}

const matches =
regex instanceof RegExp
? (operationName: string) => regex.test(operationName)
: (operationName: string) => operationName === name;

return { matches, sampleRate };
});
Comment on lines +32 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using the non-null assertion operator (!) outside of test files, as it can lead to subtle bugs. We can destructure the properties from rate and let TypeScript's control flow analysis narrow the type of regex automatically when checking regex instanceof RegExp.

  const rules = config.rates.map(rate => {
    const { name, regex, sampleRate } = rate;
    const hasName = typeof name === 'string';
    const hasRegex = regex instanceof RegExp;

    if (hasName === hasRegex) {
      throw new Error(
        'Expected usage.sampleRates entry to define exactly one of "name" or "regex".',
      );
    }

    if (sampleRate > 1 || sampleRate < 0) {
      throw new Error(
        'Expected usage.sampleRates sampleRate to be 0 <= x <= 1, received ' + sampleRate,
      );
    }

    const matches = regex instanceof RegExp
      ? (operationName: string) => regex.test(operationName)
      : (operationName: string) => operationName === name;

    return { matches, sampleRate };
  });
References
  1. Avoid using the non-null assertion operator (!) outside of test files, as it can lead to subtle bugs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed.


return function shouldInclude(context: SamplingContext): boolean {
for (const rule of rules) {
if (rule.matches(context.operationName)) {
return Math.random() <= rule.sampleRate;
}
}

return Math.random() <= config.fallbackSampleRate;
};
}

export function dynamicSampling(sampler: (context: SamplingContext) => number | boolean) {
return function shouldInclude(context: SamplingContext): boolean {
let sampleRate = sampler(context);
Expand Down
47 changes: 46 additions & 1 deletion packages/libraries/core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,31 @@ export interface HiveUsagePluginOptions {
* Default: 1.0
*/
sampleRate?: number;
/**
* Operation-level sample rates.
*
* Each rule matches operations by exact `name` or by `regex` (tested against the
* operation name) and applies its own `sampleRate`. The first matching rule wins.
* Operations that don't match any rule fall back to the global `sampleRate`
* (or 1.0 when that is not set).
*
* This is useful when a system has a mix of high-volume and low-volume operations:
* high-volume operations can be sampled aggressively while low-volume operations
* are reported at (or close to) 100%.
*
* Ignored when `sampler` is defined.
*
* @example
* sampleRates: [
* { name: 'SampledQuery', sampleRate: 0.1 },
* { regex: /^Sampled/, sampleRate: 0.1 },
* ]
*/
sampleRates?: OperationSampleRate[];
/**
* Compute sample rate dynamically.
*
* If `sampler` is defined, `sampleRate` is ignored.
* If `sampler` is defined, `sampleRate` and `sampleRates` are ignored.
*
* @returns A sample rate between 0 and 1.
* 0.0 = 0% chance of being sent
Expand All @@ -152,6 +173,30 @@ export interface HiveUsagePluginOptions {
processVariables?: boolean;
}

/**
* A sample rate that applies to a subset of operations, matched by name or regular expression.
*/
export interface OperationSampleRate {
/**
* Match operations by their exact operation name.
*
* Exactly one of `name` or `regex` must be provided.
*/
name?: string;
/**
* Match operations whose name matches this regular expression.
*
* Exactly one of `name` or `regex` must be provided.
*/
regex?: RegExp;
/**
* Sample rate applied to operations matched by this rule.
* 0.0 = 0% chance of being sent
* 1.0 = 100% chance of being sent.
*/
sampleRate: number;
}

export interface SamplingContext
extends Pick<ExecutionArgs, 'document' | 'contextValue' | 'variableValues'> {
operationName: string;
Expand Down
9 changes: 7 additions & 2 deletions packages/libraries/core/src/client/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { normalizeOperation } from '../normalize/operation.js';
import { version } from '../version.js';
import { createAgent } from './agent.js';
import { collectSchemaCoordinates } from './collect-schema-coordinates.js';
import { dynamicSampling, randomSampling } from './sampling.js';
import { dynamicSampling, operationSampling, randomSampling } from './sampling.js';
import type {
AbortAction,
ClientInfo,
Expand Down Expand Up @@ -191,7 +191,12 @@ export function createUsage(pluginOptions: HiveInternalPluginOptions): UsageColl
const shouldInclude =
options.sampler && typeof options.sampler === 'function'
? dynamicSampling(options.sampler)
: randomSampling(options.sampleRate ?? 1.0);
: options.sampleRates?.length
? operationSampling({
rates: options.sampleRates,
fallbackSampleRate: options.sampleRate ?? 1.0,
})
: randomSampling(options.sampleRate ?? 1.0);

const collectRequest: UsageCollector['collectRequest'] = args => {
let providedOperationName: string | undefined = undefined;
Expand Down
Loading