console/integration-tests/tests/api/target/usage.spec.ts
2025-12-15 09:36:16 +01:00

3569 lines
97 KiB
TypeScript

import { differenceInHours } from 'date-fns/differenceInHours';
import { formatISO } from 'date-fns/formatISO';
import { parse as parseDate } from 'date-fns/parse';
import { subHours } from 'date-fns/subHours';
import { buildASTSchema, buildSchema, parse, print, TypeInfo } from 'graphql';
import { createLogger } from 'graphql-yoga';
import { graphql } from 'testkit/gql';
import { BreakingChangeFormulaType, ProjectType } from 'testkit/gql/graphql';
import { execute } from 'testkit/graphql';
import { getServiceHost } from 'testkit/utils';
import { UTCDate } from '@date-fns/utc';
// eslint-disable-next-line hive/enforce-deps-in-dev
import { normalizeOperation } from '@graphql-hive/core';
import { createHive } from '../../../../packages/libraries/core/src';
import { collectSchemaCoordinates } from '../../../../packages/libraries/core/src/client/collect-schema-coordinates';
import { clickHouseQuery } from '../../../testkit/clickhouse';
import {
createTarget,
pollFor,
updateTargetValidationSettings,
waitFor,
} from '../../../testkit/flow';
import { initSeed } from '../../../testkit/seed';
import { CollectedOperation } from '../../../testkit/usage';
// We don't use differenceInDays from date-fns as it calculates the difference in days
// based on daylight savings time, which is not what we want here.
function differenceInDays(dateLeft: Date, dateRight: Date): number {
// https://github.com/date-fns/date-fns/blob/ddb34e0833f55020d90a1e6ccb682df3265337d6/src/differenceInDays/index.ts#L17-L18
return Math.trunc(differenceInHours(dateLeft, dateRight) / 24) | 0;
}
function parseClickHouseDate(date: string) {
return parseDate(date, 'yyyy-MM-dd HH:mm:ss', new UTCDate());
}
function ensureNumber(value: number | string): number {
if (typeof value === 'number') {
return value;
}
return parseFloat(value);
}
function prepareBatch(amount: number, operation: CollectedOperation) {
return new Array(amount).fill(operation);
}
test.concurrent(
'collect operation and publish schema using WRITE access but read operations and check schema using READ access',
{
timeout: 15_000,
},
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
toggleTargetValidation,
readOperationBody,
readOperationsStats,
waitForOperationsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const readToken = await createTargetAccessToken({
mode: 'readOnly',
});
const schemaPublishResult = await writeToken
.publishSchema({
author: 'Kamil',
commit: 'abc123',
sdl: `type Query { ping: String me: String }`,
})
.then(r => r.expectNoGraphQLErrors());
expect((schemaPublishResult.schemaPublish as any).valid).toEqual(true);
const targetValidationResult = await toggleTargetValidation(true);
expect(targetValidationResult.isEnabled).toEqual(true);
expect(targetValidationResult.percentage).toEqual(0);
expect(targetValidationResult.period).toEqual(30);
// should not be breaking because the field is unused
const unusedCheckResult = await readToken
.checkSchema(`type Query { me: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult.schemaCheck.__typename).toEqual('SchemaCheckSuccess');
const collectResult = await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
expect(collectResult.status).toEqual(200);
await waitForOperationsCollected(1);
// should be breaking because the field is used now
const usedCheckResult = await readToken
.checkSchema(`type Query { me: String }`)
.then(r => r.expectNoGraphQLErrors());
if (usedCheckResult.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${usedCheckResult.schemaCheck.__typename}`);
}
expect(usedCheckResult.schemaCheck.valid).toEqual(false);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node;
expect(op.count).toEqual(1);
await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}');
expect(op.operationHash).toBeDefined();
expect(op.duration.p75).toEqual(200);
expect(op.duration.p90).toEqual(200);
expect(op.duration.p95).toEqual(200);
expect(op.duration.p99).toEqual(200);
expect(op.kind).toEqual('query');
expect(op.name).toMatch('ping');
expect(op.percentage).toBeGreaterThan(99);
},
);
test.concurrent(
'normalize and collect operation without breaking its syntax',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
readOperationBody,
readOperationsStats,
waitForOperationsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const raw_document = `
query outfit {
recommendations(
input: {
strategies: [{ name: "asd" }]
articleId: "asd"
customerId: "asd"
phoenixEnabled: true
sessionId: "asd"
}
) {
... on RecommendationResponse {
frequentlyBoughtTogether {
recommendedProducts {
id
}
strategyMessage
}
outfit {
strategyMessage
}
outfit {
recommendedProducts {
articleId
id
imageUrl
name
productUrl
rating
tCode
}
strategyMessage
}
similar {
recommendedProducts {
articleId
id
imageUrl
name
productUrl
rating
tCode
}
strategyMessage
}
visualSearch {
strategyMessage
}
}
}
}
`;
const normalized_document = normalizeOperation({
document: parse(raw_document),
operationName: 'outfit',
hideLiterals: true,
removeAliases: true,
});
const collectResult = await writeToken.collectLegacyOperations([
{
operation: normalizeOperation({
document: parse(raw_document),
operationName: 'outfit',
hideLiterals: true,
removeAliases: true,
}),
operationName: 'outfit',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
expect(collectResult.status).toEqual(200);
await waitForOperationsCollected(1);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node;
expect(op!.count).toEqual(1);
const doc = await readOperationBody(op!.operationHash!);
if (!doc) {
throw new Error('Operation body is empty');
}
expect(() => {
parse(doc);
}).not.toThrow();
expect(print(parse(doc))).toEqual(print(parse(normalized_document)));
expect(op.operationHash).toBeDefined();
expect(op.duration.p75).toEqual(200);
expect(op.duration.p90).toEqual(200);
expect(op.duration.p95).toEqual(200);
expect(op.duration.p99).toEqual(200);
expect(op.kind).toEqual('query');
expect(op.name).toMatch('outfit');
expect(op.percentage).toBeGreaterThan(99);
},
);
test.concurrent(
'number of produced and collected operations should match (no errors)',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
readOperationsStats,
readOperationBody,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const batchSize = 1000;
const totalAmount = 10_000;
for await (const _ of new Array(totalAmount / batchSize)) {
await writeToken.collectLegacyOperations(
prepareBatch(batchSize, {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
}),
);
}
await waitForRequestsCollected(totalAmount);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
// We sent a single operation (multiple times)
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node!;
expect(op.count).toEqual(totalAmount);
await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}');
expect(op.operationHash).toBeDefined();
expect(op.duration.p75).toEqual(200);
expect(op.duration.p90).toEqual(200);
expect(op.duration.p95).toEqual(200);
expect(op.duration.p99).toEqual(200);
expect(op.kind).toEqual('query');
expect(op.name).toMatch('ping');
expect(op.percentage).toBeGreaterThan(99);
},
);
test.concurrent('check usage from two selected targets', async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { organization, createProject } = await createOrg();
const {
project,
target: staging,
createTargetAccessToken,
toggleTargetValidation,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const productionTargetResult = await createTarget(
{
slug: 'target2',
project: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
},
},
},
ownerToken,
).then(r => r.expectNoGraphQLErrors());
expect(productionTargetResult.createTarget.error).toBeNull();
const productionTarget = productionTargetResult.createTarget.ok!.createdTarget;
const stagingToken = await createTargetAccessToken({});
const productionToken = await createTargetAccessToken({
target: productionTarget,
});
const schemaPublishResult = await stagingToken
.publishSchema({
author: 'Kamil',
commit: 'usage-check-2',
sdl: `type Query { ping: String me: String }`,
})
.then(r => r.expectNoGraphQLErrors());
expect((schemaPublishResult.schemaPublish as any).valid).toEqual(true);
const targetValidationResult = await toggleTargetValidation(true);
expect(targetValidationResult.isEnabled).toEqual(true);
expect(targetValidationResult.percentage).toEqual(0);
expect(targetValidationResult.period).toEqual(30);
const collectResult = await productionToken.collectLegacyOperations([
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {},
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(3, { target: productionTarget });
// should not be breaking because the field is unused on staging
// ping is used but on production
const unusedCheckResult = await stagingToken
.checkSchema(`type Query { me: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult.schemaCheck.__typename).toEqual('SchemaCheckSuccess');
// Now switch to using checking both staging and production
const updateValidationResult = await updateTargetValidationSettings(
{
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: staging.slug,
},
},
conditionalBreakingChangeConfiguration: {
percentage: 50, // Out of 3 requests, 1 is for Query.me, 2 are done for Query.me so it's 1/3 = 33.3%
period: 2,
targetIds: [productionTarget.id, staging.id],
},
},
{
authToken: ownerToken,
},
).then(r => r.expectNoGraphQLErrors());
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.error,
).toBeNull();
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.percentage,
).toEqual(50);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.period,
).toEqual(2);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.targets,
).toHaveLength(2);
// should be non-breaking because the field is used in production and we are checking staging and production now
// and it used in less than 50% of traffic
// ping is used on production and we do check production now
const usedCheckResult = await stagingToken
.checkSchema(`type Query { me: String }`)
.then(r => r.expectNoGraphQLErrors());
if (usedCheckResult.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${usedCheckResult.schemaCheck.__typename}`);
}
expect(usedCheckResult.schemaCheck.valid).toEqual(true);
});
test.concurrent('check usage not from excluded client names', async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { organization, createProject } = await createOrg();
const {
project,
target,
createTargetAccessToken,
toggleTargetValidation,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
const schemaPublishResult = await token
.publishSchema({
author: 'Kamil',
commit: 'usage-check-2',
sdl: `type Query { ping: String me: String }`,
})
.then(r => r.expectNoGraphQLErrors());
expect((schemaPublishResult.schemaPublish as any).valid).toEqual(true);
const targetValidationResult = await toggleTargetValidation(true);
expect(targetValidationResult.isEnabled).toEqual(true);
expect(targetValidationResult.percentage).toEqual(0);
expect(targetValidationResult.period).toEqual(30);
const collectResult = await token.collectLegacyOperations([
{
timestamp: Date.now(),
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'cli',
version: '2.0.0',
},
},
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'app',
version: '1.0.0',
},
},
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'app',
version: '1.0.1',
},
},
},
{
timestamp: Date.now(),
operation: 'query me { me }',
operationName: 'me',
fields: ['Query', 'Query.me'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'cli',
version: '2.0.0',
},
},
},
]);
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(4);
// should be breaking because the field is used
// Query.me would be removed, but was requested by cli and app
const unusedCheckResult = await token
.checkSchema(`type Query { ping: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult.schemaCheck.__typename).toEqual('SchemaCheckError');
// Exclude app from the check (tests partial, incomplete exclusion)
let updateValidationResult = await updateTargetValidationSettings(
{
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
percentage: 0,
period: 2,
targetIds: [target.id],
excludedClients: ['app'],
},
},
{
authToken: ownerToken,
},
).then(r => r.expectNoGraphQLErrors());
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.error,
).toBeNull();
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.isEnabled,
).toBe(true);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.excludedClients,
).toHaveLength(1);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.excludedClients,
).toContainEqual('app');
// should be unsafe because though we excluded 'app', 'cli' still uses this
// Query.me would be removed, but was requested by cli and app
const unusedCheckResult2 = await token
.checkSchema(`type Query { ping: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult2.schemaCheck.__typename).toEqual('SchemaCheckError');
// Exclude BOTH 'app' and 'cli' (tests multi client covering exclusion)
updateValidationResult = await updateTargetValidationSettings(
{
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
percentage: 0,
period: 2,
targetIds: [target.id],
excludedClients: ['app', 'cli'],
},
},
{
authToken: ownerToken,
},
).then(r => r.expectNoGraphQLErrors());
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.excludedClients,
).toContainEqual('app');
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok!.target
.conditionalBreakingChangeConfiguration.excludedClients,
).toContainEqual('cli');
// should be safe because the field was not used by the non-excluded clients
// Query.me would be removed, but was requested by cli and app
const usedCheckResult = await (
await token.checkSchema(`type Query { ping: String }`)
).expectNoGraphQLErrors();
if (usedCheckResult.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${usedCheckResult.schemaCheck.__typename}`);
}
expect(usedCheckResult.schemaCheck.valid).toEqual(true);
});
describe('changes with usage data', () => {
function testChangesWithUsageData(input: {
title: string;
publishSdl: string;
checkSdl: string;
reportOperation: {
operation: string;
operationName: string;
fields: string[] | 'auto-collect';
};
expectedSchemaCheckTypename: {
beforeReportedOperation: 'SchemaCheckSuccess' | 'SchemaCheckError';
afterReportedOperation: 'SchemaCheckSuccess' | 'SchemaCheckError';
};
}) {
test.concurrent(input.title, async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
target,
createTargetAccessToken,
toggleTargetValidation,
waitForOperationsCollected,
readOperationsStats,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({
target,
});
const schemaPublishResult = await token
.publishSchema({
author: 'Kamil',
commit: 'initial',
sdl: input.publishSdl,
})
.then(r => r.expectNoGraphQLErrors());
expect((schemaPublishResult.schemaPublish as any).valid).toEqual(true);
await expect(
token
.checkSchema(input.checkSdl)
.then(r => r.expectNoGraphQLErrors())
.then(r => r.schemaCheck.__typename),
).resolves.toBe(input.expectedSchemaCheckTypename.beforeReportedOperation);
const targetValidationResult = await toggleTargetValidation(true);
expect(targetValidationResult.isEnabled).toEqual(true);
expect(targetValidationResult.percentage).toEqual(0);
expect(targetValidationResult.period).toEqual(30);
let fields: string[] = [];
if (input.reportOperation.fields === 'auto-collect') {
const schema = buildSchema(input.publishSdl);
fields = Array.from(
collectSchemaCoordinates({
documentNode: parse(input.reportOperation.operation),
variables: null,
processVariables: false,
schema,
typeInfo: new TypeInfo(schema),
}),
);
} else {
fields = input.reportOperation.fields;
}
const from = formatISO(subHours(Date.now(), 1));
const to = formatISO(Date.now());
const n = (await readOperationsStats(from, to)).totalOperations;
const collectResult = await token.collectLegacyOperations([
{
timestamp: Date.now(),
operation: input.reportOperation.operation,
operationName: input.reportOperation.operationName,
fields,
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {},
},
]);
expect(collectResult.status).toEqual(200);
await waitForOperationsCollected(n + 1);
await expect(
token
.checkSchema(input.checkSdl)
.then(r => r.expectNoGraphQLErrors())
.then(r => r.schemaCheck.__typename),
).resolves.toEqual(input.expectedSchemaCheckTypename.afterReportedOperation);
});
}
testChangesWithUsageData({
title: 'add non-nullable input field to used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
first: Int!
}
`,
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
expectedSchemaCheckTypename: {
// should be breaking because the input object type with new non-nullable field
beforeReportedOperation: 'SchemaCheckError',
// should be breaking because the input object type is used
afterReportedOperation: 'SchemaCheckError',
},
});
testChangesWithUsageData({
title: 'add nullable input field to used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
first: Int
}
`,
expectedSchemaCheckTypename: {
// should be safe, because it's nullable field and does not require user to provide it
beforeReportedOperation: 'SchemaCheckSuccess',
// should be safe, for the same reason
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
});
testChangesWithUsageData({
title: 'make nullable input field non-nullable of an used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int!
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it requires an action from user
beforeReportedOperation: 'SchemaCheckError',
// should be breaking, because it requires an action from user
afterReportedOperation: 'SchemaCheckError',
},
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
});
testChangesWithUsageData({
title: 'make non-nullable input nullable of an used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int!
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
}
`,
expectedSchemaCheckTypename: {
// should be safe, as it does not require any action from user
beforeReportedOperation: 'SchemaCheckSuccess',
// should be safe, as it does not require any action from user
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
});
testChangesWithUsageData({
title: 'modify type of a non-nullable input field of an used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
skip: Int!
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
skip: String!
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it's non-nullable field
beforeReportedOperation: 'SchemaCheckError',
// should be safe. Even though it's non-nullable field and the input object type is used
// BUT the field was NOT reported yet!
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
});
testChangesWithUsageData({
title: 'modify type of a nullable input field of an used input object',
publishSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
skip: Int
}
`,
checkSdl: /* GraphQL */ `
type Query {
users(filter: Filter): [String]
}
input Filter {
limit: Int
skip: String
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it changes the type of the field
beforeReportedOperation: 'SchemaCheckError',
// should be safe, because Filter.skip is not used and it's nullable
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query users { users(filter: { limit: 5 }) }',
operationName: 'users',
fields: ['Query', 'Query.users', 'Filter', 'Filter.limit'],
},
});
testChangesWithUsageData({
title: 'removing an unused union member is safe',
publishSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image | Video
type Image {
url: String
}
type Video {
url: String
}
`,
checkSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image
type Image {
url: String
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it changes the type of the field
beforeReportedOperation: 'SchemaCheckError',
// should be safe, because union member is not used
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query imageOnly { media { ... on Image { url } } }',
operationName: 'imageOnly',
fields: 'auto-collect',
},
});
testChangesWithUsageData({
title: 'removing an unused union member is safe (__typename in fragment)',
publishSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image | Video
type Image {
url: String
}
type Video {
url: String
}
`,
checkSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image
type Image {
url: String
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it changes the type of the field
beforeReportedOperation: 'SchemaCheckError',
// should be safe, because union member is not used
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query imageOnly { media { ... on Image { __typename url } } }',
operationName: 'imageOnly',
fields: 'auto-collect',
},
});
testChangesWithUsageData({
title: 'removing a used union member is a breaking change',
publishSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image | Video
type Image {
url: String
}
type Video {
url: String
}
`,
checkSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image
type Image {
url: String
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it changes the type of the field
beforeReportedOperation: 'SchemaCheckError',
// should be breaking, because union member is used
afterReportedOperation: 'SchemaCheckError',
},
reportOperation: {
operation: 'query videoOnly { media { ... on Video { url } } }',
operationName: 'videoOnly',
fields: 'auto-collect',
},
});
testChangesWithUsageData({
title: 'removing a used union member is a breaking change (__typename)',
publishSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image | Video
type Image {
url: String
}
type Video {
url: String
}
`,
checkSdl: /* GraphQL */ `
type Query {
media: Media
}
union Media = Image
type Image {
url: String
}
`,
expectedSchemaCheckTypename: {
// should be breaking, because it changes the type of the field
beforeReportedOperation: 'SchemaCheckError',
// should be breaking, because union member is referenced indirectly (__typename)
afterReportedOperation: 'SchemaCheckError',
},
reportOperation: {
operation: 'query videoOnly { media { __typename ... on Video { url } } }',
operationName: 'videoOnly',
fields: 'auto-collect',
},
});
testChangesWithUsageData({
title: 'removing a used enum value is a breaking change',
publishSdl: /* GraphQL */ `
type Query {
feed: Post
}
enum Media {
Image
Video
}
type Post {
id: ID!
title: String!
type: Media
}
`,
checkSdl: /* GraphQL */ `
type Query {
feed: Post
}
enum Media {
Image
}
type Post {
id: ID!
title: String!
type: Media
}
`,
expectedSchemaCheckTypename: {
// Should be breaking,
// because it will cause existing queries
// that use this enum value to error
beforeReportedOperation: 'SchemaCheckError',
afterReportedOperation: 'SchemaCheckError',
},
reportOperation: {
operation: 'query feed { feed { id type } }',
operationName: 'feed',
fields: 'auto-collect',
},
});
testChangesWithUsageData({
title: 'adding a new value to a used enum value is NOT a breaking change',
publishSdl: /* GraphQL */ `
type Query {
feed: Post
}
enum Media {
Image
Video
}
type Post {
id: ID!
title: String!
type: Media
}
`,
checkSdl: /* GraphQL */ `
type Query {
feed: Post
}
enum Media {
Image
Video
Audio
}
type Post {
id: ID!
title: String!
type: Media
}
`,
expectedSchemaCheckTypename: {
beforeReportedOperation: 'SchemaCheckSuccess',
afterReportedOperation: 'SchemaCheckSuccess',
},
reportOperation: {
operation: 'query feed { feed { id type } }',
operationName: 'feed',
fields: 'auto-collect',
},
});
});
test.concurrent('number of produced and collected operations should match', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
const batchSize = 1000;
const totalAmount = 10_000;
for await (const i of new Array(totalAmount / batchSize).fill(null).map((_, i) => i)) {
await writeToken.collectLegacyOperations(
prepareBatch(
batchSize,
i % 2 === 0
? {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
}
: {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'web',
version: '1.2.3',
},
},
},
),
);
}
await waitForRequestsCollected(totalAmount);
const result = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`
SELECT
target, client_name, hash, sum(total) as total
FROM clients_daily
WHERE
timestamp >= subtractDays(now(), 30)
AND timestamp <= now()
AND target = '${target.id}'
GROUP BY target, client_name, hash
`);
expect(result.rows).toEqual(2);
expect(result.data).toContainEqual(
expect.objectContaining({
target: target.id,
client_name: 'web',
hash: expect.any(String),
total: expect.stringMatching('5000'),
}),
);
expect(result.data).toContainEqual(
expect.objectContaining({
target: target.id,
client_name: '',
hash: expect.any(String),
total: expect.stringMatching('5000'),
}),
);
});
test.concurrent(
'different order of schema coordinates should not result in different hash',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }', // those spaces are expected and important to ensure normalization is in place
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query.ping', 'Query'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
await waitForRequestsCollected(2);
const coordinatesResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`
SELECT coordinate, hash FROM coordinates_daily WHERE target = '${target.id}' GROUP BY coordinate, hash
`);
expect(coordinatesResult.rows).toEqual(2);
const operationCollectionResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`SELECT hash FROM operation_collection WHERE target = '${target.id}' GROUP BY hash`);
expect(operationCollectionResult.rows).toEqual(1);
},
);
test.concurrent(
'same operation but with different schema coordinates should result in different hash',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }', // those spaces are expected and important to ensure normalization is in place
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['RootQuery', 'RootQuery.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
await waitForRequestsCollected(2);
const coordinatesResult = await clickHouseQuery<{
coordinate: string;
hash: string;
}>(`
SELECT coordinate, hash FROM coordinates_daily WHERE target = '${target.id}' GROUP BY coordinate, hash
`);
expect(coordinatesResult.rows).toEqual(4);
const operationCollectionResult = await clickHouseQuery<{
hash: string;
}>(`SELECT hash FROM operation_collection WHERE target = '${target.id}' GROUP BY hash`);
expect(operationCollectionResult.rows).toEqual(2);
const operationsResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`SELECT hash FROM operation_collection WHERE target = '${target.id}' GROUP BY hash`);
expect(operationsResult.rows).toEqual(2);
},
);
test.concurrent(
'operations with the same schema coordinates and body but with different name should result in different hashes',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
await writeToken.collectLegacyOperations([
{
operation: 'query pingv2 { ping }',
operationName: 'pingv2',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
await waitForRequestsCollected(2);
const coordinatesResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`
SELECT coordinate, hash FROM coordinates_daily WHERE target = '${target.id}' GROUP BY coordinate, hash
`);
expect(coordinatesResult.rows).toEqual(4);
const operationsResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`SELECT hash FROM operation_collection WHERE target = '${target.id}' GROUP BY hash`);
expect(operationsResult.rows).toEqual(2);
},
);
test.concurrent('ignore operations with syntax errors', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
const collectResult = await writeToken.collectLegacyOperations([
{
operation: 'query pingv2 { pingv2 }',
operationName: 'pingv2',
fields: ['Query', 'Query.pingv2'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
]);
expect(collectResult.status).toEqual(200);
expect(typeof collectResult.body !== 'string' && collectResult.body.operations).toEqual(
expect.objectContaining({
rejected: 1,
accepted: 1,
}),
);
// @note rejected doesnt count... Assume if 1 has been collected that the other has too.
await waitForRequestsCollected(1);
const coordinatesResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`
SELECT coordinate, hash FROM coordinates_daily WHERE target = '${target.id}' GROUP BY coordinate, hash
`);
expect(coordinatesResult.rows).toEqual(2);
const operationsResult = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`SELECT hash FROM operation_collection WHERE target = '${target.id}' GROUP BY hash`);
expect(operationsResult.rows).toEqual(1);
});
test.concurrent('ensure correct data', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
// Organization was created, but the rate limiter may be not aware of it yet.
await waitFor(6_000); // so the data retention is propagated to the rate-limiter
await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }', // those spaces are expected and important to ensure normalization is in place
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'test-name',
version: 'test-version',
},
},
},
]);
await waitForRequestsCollected(2);
// operation_collection
const operationCollectionResult = await clickHouseQuery<{
target: string;
hash: string;
name: string;
body: string;
operation_kind: string;
coordinates: string[];
total: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
hash,
name,
body,
operation_kind,
sum(total) as total,
coordinates,
timestamp,
expires_at
FROM operation_collection
WHERE target = '${target.id}'
GROUP BY target, hash, coordinates, name, body, operation_kind, timestamp, expires_at
`);
expect(operationCollectionResult.data).toHaveLength(1);
const operationCollectionRow = operationCollectionResult.data[0];
expect(operationCollectionRow.body).toEqual('query ping{ping}');
expect(operationCollectionRow.coordinates).toHaveLength(2);
expect(operationCollectionRow.coordinates).toContainEqual('Query.ping');
expect(operationCollectionRow.coordinates).toContainEqual('Query');
expect(operationCollectionRow.hash).toHaveLength(32);
expect(operationCollectionRow.name).toBe('ping');
expect(operationCollectionRow.target).toBe(target.id);
expect(ensureNumber(operationCollectionRow.total)).toEqual(2);
expect(
differenceInDays(
parseClickHouseDate(operationCollectionRow.expires_at),
parseClickHouseDate(operationCollectionRow.timestamp),
),
).toBe(organization.usageRetentionInDays);
// operations
const operationsResult = await clickHouseQuery<{
target: string;
timestamp: string;
expires_at: string;
hash: string;
ok: boolean;
errors: number;
duration: number;
client_name: string;
client_version: string;
}>(`
SELECT
target,
timestamp,
expires_at,
hash,
ok,
errors,
duration,
client_name,
client_version
FROM operations
WHERE target = '${target.id}'
`);
expect(operationsResult.data).toHaveLength(2);
const operationWithClient = operationsResult.data.find(o => o.client_name.length > 0)!;
expect(operationWithClient).toBeDefined();
expect(operationWithClient.client_name).toEqual('test-name');
expect(operationWithClient.client_version).toEqual('test-version');
expect(ensureNumber(operationWithClient.duration)).toEqual(200_000_000);
expect(ensureNumber(operationWithClient.errors)).toEqual(0);
expect(operationWithClient.hash).toHaveLength(32);
expect(operationWithClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(operationWithClient.expires_at),
parseClickHouseDate(operationWithClient.timestamp),
),
).toBe(organization.usageRetentionInDays);
const operationWithoutClient = operationsResult.data.find(o => o.client_name.length === 0)!;
expect(operationWithoutClient).toBeDefined();
expect(operationWithoutClient.client_name).toHaveLength(0);
expect(operationWithoutClient.client_version).toHaveLength(0);
expect(ensureNumber(operationWithoutClient.duration)).toEqual(200_000_000);
expect(ensureNumber(operationWithoutClient.errors)).toEqual(0);
expect(operationWithoutClient.hash).toHaveLength(32);
expect(operationWithoutClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(operationWithoutClient.expires_at),
parseClickHouseDate(operationWithoutClient.timestamp),
),
).toBe(organization.usageRetentionInDays);
// operations_hourly
const operationsHourlyResult = await clickHouseQuery<{
target: string;
hash: string;
total_ok: string;
total: string;
quantiles: [number];
}>(`
SELECT
target,
sum(total) as total,
sum(total_ok) as total_ok,
hash,
quantilesMerge(0.99)(duration_quantiles) as quantiles
FROM operations_hourly
WHERE target = '${target.id}'
GROUP BY target, hash
`);
expect(operationsHourlyResult.data).toHaveLength(1);
const hourlyAgg = operationsHourlyResult.data[0];
expect(hourlyAgg).toBeDefined();
expect(ensureNumber(hourlyAgg.quantiles[0])).toEqual(200_000_000);
expect(ensureNumber(hourlyAgg.total)).toEqual(2);
expect(ensureNumber(hourlyAgg.total_ok)).toEqual(2);
expect(hourlyAgg.hash).toHaveLength(32);
expect(hourlyAgg.target).toEqual(target.id);
// operations_daily
const operationsDailyResult = await clickHouseQuery<{
target: string;
hash: string;
total_ok: string;
total: string;
quantiles: [number];
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
sum(total) as total,
sum(total_ok) as total_ok,
hash,
quantilesMerge(0.99)(duration_quantiles) as quantiles,
timestamp,
expires_at
FROM operations_daily
WHERE target = '${target.id}'
GROUP BY target, hash, timestamp, expires_at
`);
expect(operationsDailyResult.data).toHaveLength(1);
const dailyAgg = operationsDailyResult.data[0];
expect(dailyAgg).toBeDefined();
expect(ensureNumber(dailyAgg.quantiles[0])).toEqual(200_000_000);
expect(ensureNumber(dailyAgg.total)).toEqual(2);
expect(ensureNumber(dailyAgg.total_ok)).toEqual(2);
expect(dailyAgg.hash).toHaveLength(32);
expect(dailyAgg.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAgg.expires_at),
parseClickHouseDate(dailyAgg.timestamp),
),
).toBe(organization.usageRetentionInDays);
// coordinates_daily
const coordinatesDailyResult = await clickHouseQuery<{
target: string;
hash: string;
total: string;
coordinate: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
sum(total) as total,
hash,
coordinate,
timestamp,
expires_at
FROM coordinates_daily
WHERE target = '${target.id}'
GROUP BY target, hash, coordinate, timestamp, expires_at
`);
expect(coordinatesDailyResult.data).toHaveLength(2);
const rootCoordinate = coordinatesDailyResult.data.find(c => c.coordinate === 'Query')!;
expect(rootCoordinate).toBeDefined();
expect(ensureNumber(rootCoordinate.total)).toEqual(2);
expect(rootCoordinate.hash).toHaveLength(32);
expect(rootCoordinate.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(rootCoordinate.expires_at),
parseClickHouseDate(rootCoordinate.timestamp),
),
).toBe(organization.usageRetentionInDays);
const fieldCoordinate = coordinatesDailyResult.data.find(c => c.coordinate === 'Query.ping')!;
expect(fieldCoordinate).toBeDefined();
expect(ensureNumber(fieldCoordinate.total)).toEqual(2);
expect(fieldCoordinate.hash).toHaveLength(32);
expect(fieldCoordinate.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(fieldCoordinate.expires_at),
parseClickHouseDate(fieldCoordinate.timestamp),
),
).toBe(organization.usageRetentionInDays);
// clients_daily
const clientsDailyResult = await clickHouseQuery<{
target: string;
hash: string;
client_name: string;
client_version: string;
total: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
sum(total) as total,
hash,
client_name,
client_version,
timestamp,
expires_at
FROM clients_daily
WHERE target = '${target.id}'
GROUP BY target, hash, client_name, client_version, timestamp, expires_at
`);
expect(clientsDailyResult.data).toHaveLength(2);
const dailyAggOfKnownClient = clientsDailyResult.data.find(c => c.client_name === 'test-name')!;
expect(dailyAggOfKnownClient).toBeDefined();
expect(ensureNumber(dailyAggOfKnownClient.total)).toEqual(1);
expect(dailyAggOfKnownClient.client_version).toBe('test-version');
expect(dailyAggOfKnownClient.hash).toHaveLength(32);
expect(dailyAggOfKnownClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAggOfKnownClient.expires_at),
parseClickHouseDate(dailyAggOfKnownClient.timestamp),
),
).toBe(organization.usageRetentionInDays);
const dailyAggOfUnknownClient = clientsDailyResult.data.find(c => c.client_name !== 'test-name')!;
expect(dailyAggOfUnknownClient).toBeDefined();
expect(ensureNumber(dailyAggOfUnknownClient.total)).toEqual(1);
expect(dailyAggOfUnknownClient.client_version).toHaveLength(0);
expect(dailyAggOfUnknownClient.hash).toHaveLength(32);
expect(dailyAggOfUnknownClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAggOfUnknownClient.expires_at),
parseClickHouseDate(dailyAggOfUnknownClient.timestamp),
),
).toBe(organization.usageRetentionInDays);
});
test.concurrent(
'ensure correct data when data retention period is non-default',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject, setDataRetention } = await createOrg();
const { target, createTargetAccessToken, waitForRequestsCollected } = await createProject(
ProjectType.Single,
);
const writeToken = await createTargetAccessToken({});
const dataRetentionInDays = 60;
await setDataRetention(dataRetentionInDays);
await waitFor(10_000); // so the data retention is propagated to the rate-limiter
await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }', // those spaces are expected and important to ensure normalization is in place
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
},
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'test-name',
version: 'test-version',
},
},
},
]);
await waitForRequestsCollected(2);
// operation_collection
const operationCollectionResult = await clickHouseQuery<{
target: string;
hash: string;
name: string;
body: string;
operation_kind: string;
coordinates: string[];
total: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
hash,
name,
body,
operation_kind,
sum(total) as total,
coordinates,
timestamp,
expires_at
FROM operation_collection
WHERE target = '${target.id}'
GROUP BY target, hash, coordinates, name, body, operation_kind, timestamp, expires_at
`);
expect(operationCollectionResult.data).toHaveLength(1);
const operationCollectionRow = operationCollectionResult.data[0];
expect(operationCollectionRow.body).toEqual('query ping{ping}');
expect(operationCollectionRow.coordinates).toHaveLength(2);
expect(operationCollectionRow.coordinates).toContainEqual('Query.ping');
expect(operationCollectionRow.coordinates).toContainEqual('Query');
expect(operationCollectionRow.hash).toHaveLength(32);
expect(operationCollectionRow.name).toBe('ping');
expect(operationCollectionRow.target).toBe(target.id);
expect(ensureNumber(operationCollectionRow.total)).toEqual(2);
expect(
differenceInDays(
parseClickHouseDate(operationCollectionRow.expires_at),
parseClickHouseDate(operationCollectionRow.timestamp),
),
).toBe(dataRetentionInDays);
// operations
const operationsResult = await clickHouseQuery<{
target: string;
timestamp: string;
expires_at: string;
hash: string;
ok: boolean;
errors: number;
duration: number;
client_name: string;
client_version: string;
}>(`
SELECT
target,
timestamp,
expires_at,
hash,
ok,
errors,
duration,
client_name,
client_version
FROM operations
WHERE target = '${target.id}'
`);
expect(operationsResult.data).toHaveLength(2);
const operationWithClient = operationsResult.data.find(o => o.client_name.length > 0)!;
expect(operationWithClient).toBeDefined();
expect(operationWithClient.client_name).toEqual('test-name');
expect(operationWithClient.client_version).toEqual('test-version');
expect(ensureNumber(operationWithClient.duration)).toEqual(200_000_000);
expect(ensureNumber(operationWithClient.errors)).toEqual(0);
expect(operationWithClient.hash).toHaveLength(32);
expect(operationWithClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(operationWithClient.expires_at),
parseClickHouseDate(operationWithClient.timestamp),
),
).toBe(dataRetentionInDays);
const operationWithoutClient = operationsResult.data.find(o => o.client_name.length === 0)!;
expect(operationWithoutClient).toBeDefined();
expect(operationWithoutClient.client_name).toHaveLength(0);
expect(operationWithoutClient.client_version).toHaveLength(0);
expect(ensureNumber(operationWithoutClient.duration)).toEqual(200_000_000);
expect(ensureNumber(operationWithoutClient.errors)).toEqual(0);
expect(operationWithoutClient.hash).toHaveLength(32);
expect(operationWithoutClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(operationWithoutClient.expires_at),
parseClickHouseDate(operationWithoutClient.timestamp),
),
).toBe(dataRetentionInDays);
await waitFor(3000);
// operations_hourly
const operationsHourlyResult = await clickHouseQuery<{
target: string;
hash: string;
total_ok: string;
total: string;
quantiles: [number];
}>(`
SELECT
target,
sum(total) as total,
sum(total_ok) as total_ok,
hash,
quantilesMerge(0.99)(duration_quantiles) as quantiles
FROM operations_hourly
WHERE target = '${target.id}'
GROUP BY target, hash
`);
expect(operationsHourlyResult.data).toHaveLength(1);
const hourlyAgg = operationsHourlyResult.data[0];
expect(hourlyAgg).toBeDefined();
expect(ensureNumber(hourlyAgg.quantiles[0])).toEqual(200_000_000);
expect(ensureNumber(hourlyAgg.total)).toEqual(2);
expect(ensureNumber(hourlyAgg.total_ok)).toEqual(2);
expect(hourlyAgg.hash).toHaveLength(32);
expect(hourlyAgg.target).toEqual(target.id);
// operations_daily
const operationsDailyResult = await clickHouseQuery<{
target: string;
timestamp: string;
expires_at: string;
hash: string;
total_ok: string;
total: string;
quantiles: [number];
}>(`
SELECT
target,
timestamp,
expires_at,
sum(total) as total,
sum(total_ok) as total_ok,
hash,
quantilesMerge(0.99)(duration_quantiles) as quantiles
FROM operations_daily
WHERE target = '${target.id}'
GROUP BY target, hash, timestamp, expires_at
`);
expect(operationsDailyResult.data).toHaveLength(1);
const dailyAgg = operationsDailyResult.data[0];
expect(dailyAgg).toBeDefined();
expect(ensureNumber(dailyAgg.quantiles[0])).toEqual(200_000_000);
expect(ensureNumber(dailyAgg.total)).toEqual(2);
expect(ensureNumber(dailyAgg.total_ok)).toEqual(2);
expect(dailyAgg.hash).toHaveLength(32);
expect(dailyAgg.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAgg.expires_at),
parseClickHouseDate(dailyAgg.timestamp),
),
).toBe(dataRetentionInDays);
// coordinates_daily
const coordinatesDailyResult = await clickHouseQuery<{
target: string;
hash: string;
total: string;
coordinate: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
sum(total) as total,
hash,
coordinate,
timestamp,
expires_at
FROM coordinates_daily
WHERE target = '${target.id}'
GROUP BY target, hash, coordinate, timestamp, expires_at
`);
expect(coordinatesDailyResult.data).toHaveLength(2);
const rootCoordinate = coordinatesDailyResult.data.find(c => c.coordinate === 'Query')!;
expect(rootCoordinate).toBeDefined();
expect(ensureNumber(rootCoordinate.total)).toEqual(2);
expect(rootCoordinate.hash).toHaveLength(32);
expect(rootCoordinate.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(rootCoordinate.expires_at),
parseClickHouseDate(rootCoordinate.timestamp),
),
).toBe(dataRetentionInDays);
const fieldCoordinate = coordinatesDailyResult.data.find(c => c.coordinate === 'Query.ping')!;
expect(fieldCoordinate).toBeDefined();
expect(ensureNumber(fieldCoordinate.total)).toEqual(2);
expect(fieldCoordinate.hash).toHaveLength(32);
expect(fieldCoordinate.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(fieldCoordinate.expires_at),
parseClickHouseDate(fieldCoordinate.timestamp),
),
).toBe(dataRetentionInDays);
// clients_daily
const clientsDailyResult = await clickHouseQuery<{
target: string;
hash: string;
client_name: string;
client_version: string;
total: string;
timestamp: string;
expires_at: string;
}>(`
SELECT
target,
sum(total) as total,
hash,
client_name,
client_version,
timestamp,
expires_at
FROM clients_daily
WHERE target = '${target.id}'
GROUP BY target, hash, client_name, client_version, timestamp, expires_at
`);
expect(clientsDailyResult.data).toHaveLength(2);
const dailyAggOfKnownClient = clientsDailyResult.data.find(c => c.client_name === 'test-name')!;
expect(dailyAggOfKnownClient).toBeDefined();
expect(ensureNumber(dailyAggOfKnownClient.total)).toEqual(1);
expect(dailyAggOfKnownClient.client_version).toBe('test-version');
expect(dailyAggOfKnownClient.hash).toHaveLength(32);
expect(dailyAggOfKnownClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAggOfKnownClient.expires_at),
parseClickHouseDate(dailyAggOfKnownClient.timestamp),
),
).toBe(dataRetentionInDays);
const dailyAggOfUnknownClient = clientsDailyResult.data.find(
c => c.client_name !== 'test-name',
)!;
expect(dailyAggOfUnknownClient).toBeDefined();
expect(ensureNumber(dailyAggOfUnknownClient.total)).toEqual(1);
expect(dailyAggOfUnknownClient.client_version).toHaveLength(0);
expect(dailyAggOfUnknownClient.hash).toHaveLength(32);
expect(dailyAggOfUnknownClient.target).toEqual(target.id);
expect(
differenceInDays(
parseClickHouseDate(dailyAggOfUnknownClient.expires_at),
parseClickHouseDate(dailyAggOfUnknownClient.timestamp),
),
).toBe(dataRetentionInDays);
},
);
const SubscriptionSchemaCheckQuery = graphql(/* GraphQL */ `
query SubscriptionSchemaCheck($selector: TargetSelectorInput!, $id: ID!) {
target(reference: { bySelector: $selector }) {
schemaCheck(id: $id) {
__typename
id
breakingSchemaChanges {
nodes {
criticality
criticalityReason
message
path
isSafeBasedOnUsage
usageStatistics {
topAffectedOperations {
hash
name
countFormatted
percentage
percentageFormatted
operation {
hash
name
type
body
}
}
topAffectedClients {
name
countFormatted
percentage
percentageFormatted
}
}
approval {
schemaCheckId
approvedAt
approvedBy {
id
displayName
}
}
}
}
}
}
}
`);
test.concurrent(
'test threshold when using conditional breaking change "PERCENTAGE" detection',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
toggleTargetValidation,
updateTargetValidationSettings,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
a: String
b: String
c: String
}
`;
const queryA = parse(/* GraphQL */ `
query {
a
}
`);
const queryB = parse(/* GraphQL */ `
query {
b
}
`);
function collectA() {
client.collectUsage()(
{
document: queryA,
schema,
contextValue: {
request,
},
},
{},
);
}
function collectB() {
client.collectUsage()(
{
document: queryB,
schema,
contextValue: {
request,
},
},
{},
);
}
const schema = buildASTSchema(parse(sdl));
const schemaPublishResult = await token
.publishSchema({
sdl,
author: 'Kamil',
commit: 'initial',
})
.then(res => res.expectNoGraphQLErrors());
expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');
await toggleTargetValidation(true);
const unused = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (unused.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${unused.schemaCheck.__typename}`);
}
expect(unused.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'integration-tests',
'x-graphql-client-version': '6.6.6',
},
});
collectA();
await waitForRequestsCollected(1);
const used = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (used.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${used.schemaCheck.__typename}`);
}
expect(used.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Query'",
},
],
total: 1,
});
// Now let's make Query.a below threshold by making 3 queries for Query.b
collectB();
collectB();
collectB();
await updateTargetValidationSettings({
excludedClients: [],
percentage: 50,
});
await waitForRequestsCollected(4);
const below = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (below.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${below.schemaCheck.__typename}`);
}
expect(below.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
// Make it above threshold again, by making 3 queries for Query.a
collectA();
collectA();
collectA();
await waitForRequestsCollected(7);
const relevant = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (relevant.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${relevant.schemaCheck.__typename}`);
}
expect(relevant.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Query'",
},
],
total: 1,
});
},
);
test.concurrent(
'test threshold when using conditional breaking change "REQUEST_COUNT" detection',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
toggleTargetValidation,
updateTargetValidationSettings,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
await toggleTargetValidation(true);
await updateTargetValidationSettings({
excludedClients: [],
requestCount: 2,
percentage: 0,
breakingChangeFormula: BreakingChangeFormulaType.RequestCount,
});
const sdl = /* GraphQL */ `
type Query {
a: String
b: String
c: String
}
`;
const queryA = parse(/* GraphQL */ `
query {
a
}
`);
function collectA() {
client.collectUsage()(
{
document: queryA,
schema,
contextValue: {
request,
},
},
{},
);
}
const schema = buildASTSchema(parse(sdl));
const schemaPublishResult = await token
.publishSchema({
sdl,
author: 'Kamil',
commit: 'initial',
})
.then(res => res.expectNoGraphQLErrors());
expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');
const unused = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (unused.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${unused.schemaCheck.__typename}`);
}
expect(unused.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'integration-tests',
'x-graphql-client-version': '6.6.6',
},
});
collectA();
await waitForRequestsCollected(1);
const below = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (below.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${below.schemaCheck.__typename}`);
}
expect(below.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
// Now let's make Query.a above threshold by making a 2nd query for Query.a
collectA();
await waitForRequestsCollected(2);
const above = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (above.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${above.schemaCheck.__typename}`);
}
expect(above.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Query'",
},
],
total: 1,
});
},
);
test.concurrent(
'test threshold when using conditional breaking change "REQUEST_COUNT" detection, across multiple operations',
async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
toggleTargetValidation,
updateTargetValidationSettings,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
await toggleTargetValidation(true);
await updateTargetValidationSettings({
excludedClients: [],
requestCount: 2,
percentage: 0,
breakingChangeFormula: BreakingChangeFormulaType.RequestCount,
});
const sdl = /* GraphQL */ `
type Query {
a: String
b: String
c: String
}
`;
const queryA = parse(/* GraphQL */ `
query {
a
}
`);
const queryB = parse(/* GraphQL */ `
query {
a
b
}
`);
function collectA() {
client.collectUsage()(
{
document: queryA,
schema,
contextValue: {
request,
},
},
{},
);
}
function collectB() {
client.collectUsage()(
{
document: queryB,
schema,
contextValue: {
request,
},
},
{},
);
}
const schema = buildASTSchema(parse(sdl));
const schemaPublishResult = await token
.publishSchema({
sdl,
author: 'Kamil',
commit: 'initial',
})
.then(res => res.expectNoGraphQLErrors());
expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'integration-tests',
'x-graphql-client-version': '6.6.6',
},
});
collectA();
collectB();
await waitForRequestsCollected(2);
// try to remove `Query.a`
const above = await token
.checkSchema(/* GraphQL */ `
type Query {
b: String
c: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (above.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${above.schemaCheck.__typename}`);
}
expect(above.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Query'",
},
],
total: 1,
});
},
);
test.concurrent(
'subscription operation is used for conditional breaking change detection',
async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { organization, createProject } = await createOrg();
const {
project,
target,
createTargetAccessToken,
toggleTargetValidation,
updateTargetValidationSettings,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
const sdl = /* GraphQL */ `
type Query {
a: String
b: String
}
type Subscription {
a: String
b: String
}
`;
const schema = buildASTSchema(parse(sdl));
const schemaPublishResult = await token
.publishSchema({
sdl,
author: 'Kamil',
commit: 'initial',
})
.then(res => res.expectNoGraphQLErrors());
expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess');
await toggleTargetValidation(true);
const unused = await token
.checkSchema(/* GraphQL */ `
type Query {
a: String
b: String
}
type Subscription {
b: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (unused.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${unused.schemaCheck.__typename}`);
}
expect(unused.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message:
"Field 'a' was removed from object type 'Subscription' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
const usageAddress = await getServiceHost('usage', 8081);
const client = createHive({
enabled: true,
token: token.secret,
usage: true,
debug: false,
agent: {
logger: createLogger('debug'),
maxSize: 1,
},
selfHosting: {
usageEndpoint: 'http://' + usageAddress,
graphqlEndpoint: 'http://noop/',
applicationUrl: 'http://noop/',
},
});
const request = new Request('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'x-graphql-client-name': 'integration-tests',
'x-graphql-client-version': '6.6.6',
},
});
client.collectSubscriptionUsage({
args: {
document: parse('subscription { a }'),
schema,
contextValue: {
request,
},
},
});
let firstSchemaCheckId: string | undefined;
await pollFor(async () => {
try {
const used = await token
.checkSchema(/* GraphQL */ `
type Query {
a: String
b: String
}
type Subscription {
b: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (used.schemaCheck.__typename === 'SchemaCheckError') {
expect(used.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Subscription'",
},
],
total: 1,
});
firstSchemaCheckId = used.schemaCheck.schemaCheck?.id;
return true;
}
return false;
} catch (e) {
console.error(e);
return false;
}
});
if (!firstSchemaCheckId) {
throw new Error('Expected schemaCheckId to be defined');
}
const firstSchemaCheck = await execute({
document: SubscriptionSchemaCheckQuery,
variables: {
id: firstSchemaCheckId,
selector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
const node = firstSchemaCheck.target?.schemaCheck?.breakingSchemaChanges?.nodes[0];
if (!node) {
throw new Error('Expected node to be defined');
}
expect(node.isSafeBasedOnUsage).toEqual(false);
expect(node.usageStatistics?.topAffectedOperations).toEqual([
{
countFormatted: '1',
hash: 'c1bbc8385a4a6f4e4988be7394800adc',
name: 'anonymous',
percentage: 100,
percentageFormatted: '100.00%',
operation: {
body: 'subscription{a}',
hash: 'c1bbc8385a4a6f4e4988be7394800adc',
name: 'anonymous',
type: 'SUBSCRIPTION',
},
},
]);
expect(node.usageStatistics?.topAffectedClients).toEqual([
{
countFormatted: '1',
name: 'integration-tests',
percentage: 100,
percentageFormatted: '100.00%',
},
]);
// Now let's make subscription insignificant by making 3 queries
client.collectUsage()(
{
document: parse('{ a }'),
schema,
contextValue: {
request,
},
},
{},
);
client.collectUsage()(
{
document: parse('{ a }'),
schema,
contextValue: {
request,
},
},
{},
);
client.collectUsage()(
{
document: parse('{ a }'),
schema,
contextValue: {
request,
},
},
{},
);
await updateTargetValidationSettings({
excludedClients: [],
percentage: 50,
});
await pollFor(async () => {
try {
const irrelevant = await token
.checkSchema(/* GraphQL */ `
type Query {
a: String
b: String
}
type Subscription {
b: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (irrelevant?.schemaCheck.__typename === 'SchemaCheckSuccess') {
expect(irrelevant.schemaCheck.changes).toEqual(
expect.objectContaining({
nodes: expect.arrayContaining([
expect.objectContaining({
message:
"Field 'a' was removed from object type 'Subscription' (non-breaking based on usage)",
}),
]),
total: 1,
}),
);
return true;
}
return false;
} catch (e) {
console.error(e);
return false;
}
});
// Make it relevant again, by making 3 subscriptions
client.collectSubscriptionUsage({
args: {
document: parse('subscription { a }'),
schema,
contextValue: {
request,
},
},
});
client.collectSubscriptionUsage({
args: {
document: parse('subscription { a }'),
schema,
contextValue: {
request,
},
},
});
client.collectSubscriptionUsage({
args: {
document: parse('subscription { a }'),
schema,
contextValue: {
request,
},
},
});
await pollFor(async () => {
try {
const relevant = await token
.checkSchema(/* GraphQL */ `
type Query {
a: String
b: String
}
type Subscription {
b: String
}
`)
.then(r => r.expectNoGraphQLErrors());
if (relevant.schemaCheck.__typename === 'SchemaCheckError') {
expect(relevant.schemaCheck.errors).toEqual({
nodes: [
{
message: "Field 'a' was removed from object type 'Subscription'",
},
],
total: 1,
});
return true;
}
return false;
} catch (e) {
console.error(e);
return false;
}
});
},
);
test.concurrent('ensure percentage precision up to 2 decimal places', async ({ expect }) => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const {
project,
target,
createTargetAccessToken,
toggleTargetValidation,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const token = await createTargetAccessToken({});
const schemaPublishResult = await token
.publishSchema({
author: 'Kamil',
commit: 'abc123',
sdl: `type Query { ping: String pong: String }`,
})
.then(r => r.expectNoGraphQLErrors());
expect((schemaPublishResult.schemaPublish as any).valid).toEqual(true);
await token.collectLegacyOperations(
prepareBatch(9801, {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
}),
);
await token.collectLegacyOperations(
prepareBatch(199, {
operation: 'query pong { pong }',
operationName: 'pong',
fields: ['Query', 'Query.pong'],
execution: {
ok: true,
duration: 100_000_000,
errorsTotal: 0,
},
}),
);
await waitForRequestsCollected(9801 + 199);
const result = await clickHouseQuery<{
target: string;
client_name: string | null;
hash: string;
total: number;
}>(`
SELECT
target, sum(total) as total
FROM clients_daily
WHERE
timestamp >= subtractDays(now(), 30)
AND timestamp <= now()
AND target = '${target.id}'
GROUP BY target
`);
expect(result.rows).toEqual(1);
expect(result.data).toContainEqual(
expect.objectContaining({
target: target.id,
total: expect.stringMatching('10000'),
}),
);
const targetValidationResult = await toggleTargetValidation(true);
expect(targetValidationResult.isEnabled).toEqual(true);
// should accept a breaking change when percentage is 2%
let updateValidationResult = await updateTargetValidationSettings(
{
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
percentage: 2,
period: 2,
targetIds: [target.id],
excludedClients: [],
},
},
{
token: ownerToken,
},
).then(r => r.expectNoGraphQLErrors());
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok?.target
.conditionalBreakingChangeConfiguration.isEnabled,
).toBe(true);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok?.target
.conditionalBreakingChangeConfiguration.percentage,
).toBe(2);
const unusedCheckResult2 = await token
.checkSchema(`type Query { ping: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult2.schemaCheck.__typename).toEqual('SchemaCheckSuccess');
// should reject a breaking change when percentage is 1.99%
updateValidationResult = await updateTargetValidationSettings(
{
target: {
bySelector: {
organizationSlug: organization.slug,
projectSlug: project.slug,
targetSlug: target.slug,
},
},
conditionalBreakingChangeConfiguration: {
percentage: 1.99,
period: 2,
targetIds: [target.id],
excludedClients: [],
},
},
{
token: ownerToken,
},
).then(r => r.expectNoGraphQLErrors());
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok?.target
.conditionalBreakingChangeConfiguration.isEnabled,
).toBe(true);
expect(
updateValidationResult.updateTargetConditionalBreakingChangeConfiguration.ok?.target
.conditionalBreakingChangeConfiguration.percentage,
).toBe(1.99);
const unusedCheckResult199 = await token
.checkSchema(`type Query { ping: String }`)
.then(r => r.expectNoGraphQLErrors());
expect(unusedCheckResult199.schemaCheck.__typename).toEqual('SchemaCheckError');
});
test.concurrent('(legacy) collect an operation from "unknown" client', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
readOperationBody,
readOperationsStats,
readClientStats,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const collectResult = await writeToken.collectLegacyOperations([
{
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'unknown',
version: 'v1.2.3',
},
},
},
]);
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(1);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node;
expect(operationsStats).toMatchInlineSnapshot(`
{
clients: {
edges: [
{
node: {
count: 1,
name: unknown,
versions: [
{
count: 1,
version: v1.2.3,
},
],
},
},
],
},
operations: {
edges: [
{
node: {
count: 1,
duration: {
p75: 200,
p90: 200,
p95: 200,
p99: 200,
},
id: 8f87d0bc9744ad3d50af125d20c355c0,
kind: query,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
percentage: 100,
},
},
],
},
totalOperations: 1,
}
`);
await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}');
const clientStats = await readClientStats({
clientName: 'unknown',
from,
to,
});
expect(clientStats).toMatchInlineSnapshot(`
{
operations: {
edges: [
{
node: {
count: 1,
id: 8f87d0bc9744ad3d50af125d20c355c0,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
},
},
],
},
totalRequests: 1,
totalVersions: 1,
versions: [
{
count: 1,
version: v1.2.3,
},
],
}
`);
});
test.concurrent('collect an operation from "unknown" client', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
readOperationBody,
readOperationsStats,
readClientStats,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const collectResult = await writeToken.collectUsage({
size: 1,
map: {
op1: {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
},
},
operations: [
{
operationMapKey: 'op1',
timestamp: Date.now(),
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: 'unknown',
version: 'v1.2.3',
},
},
},
],
});
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(1);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node;
expect(operationsStats).toMatchInlineSnapshot(`
{
clients: {
edges: [
{
node: {
count: 1,
name: unknown,
versions: [
{
count: 1,
version: v1.2.3,
},
],
},
},
],
},
operations: {
edges: [
{
node: {
count: 1,
duration: {
p75: 200,
p90: 200,
p95: 200,
p99: 200,
},
id: 8f87d0bc9744ad3d50af125d20c355c0,
kind: query,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
percentage: 100,
},
},
],
},
totalOperations: 1,
}
`);
await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}');
const clientStats = await readClientStats({
clientName: 'unknown',
from,
to,
});
expect(clientStats).toMatchInlineSnapshot(`
{
operations: {
edges: [
{
node: {
count: 1,
id: 8f87d0bc9744ad3d50af125d20c355c0,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
},
},
],
},
totalRequests: 1,
totalVersions: 1,
versions: [
{
count: 1,
version: v1.2.3,
},
],
}
`);
});
test.concurrent('collect an operation from undefined client', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const {
createTargetAccessToken,
readOperationBody,
readOperationsStats,
readClientStats,
waitForRequestsCollected,
} = await createProject(ProjectType.Single);
const writeToken = await createTargetAccessToken({});
const collectResult = await writeToken.collectUsage({
size: 1,
map: {
op1: {
operation: 'query ping { ping }',
operationName: 'ping',
fields: ['Query', 'Query.ping'],
},
},
operations: [
{
operationMapKey: 'op1',
timestamp: Date.now(),
execution: {
ok: true,
duration: 200_000_000,
errorsTotal: 0,
},
metadata: {
client: {
name: undefined as any,
version: 'v1.2.3',
},
},
},
],
});
expect(collectResult.status).toEqual(200);
await waitForRequestsCollected(1);
const from = formatISO(subHours(Date.now(), 6));
const to = formatISO(Date.now());
const operationsStats = await readOperationsStats(from, to);
expect(operationsStats.operations.edges).toHaveLength(1);
const op = operationsStats.operations.edges[0].node;
expect(operationsStats).toMatchInlineSnapshot(`
{
clients: {
edges: [
{
node: {
count: 1,
name: unknown,
versions: [
{
count: 1,
version: v1.2.3,
},
],
},
},
],
},
operations: {
edges: [
{
node: {
count: 1,
duration: {
p75: 200,
p90: 200,
p95: 200,
p99: 200,
},
id: 8f87d0bc9744ad3d50af125d20c355c0,
kind: query,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
percentage: 100,
},
},
],
},
totalOperations: 1,
}
`);
await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}');
const clientStats = await readClientStats({
clientName: 'unknown',
from,
to,
});
expect(clientStats).toMatchInlineSnapshot(`
{
operations: {
edges: [
{
node: {
count: 1,
id: 8f87d0bc9744ad3d50af125d20c355c0,
name: 798a_ping,
operationHash: 798ae10ebeef9f632ceec2fbe85a2052,
},
},
],
},
totalRequests: 1,
totalVersions: 1,
versions: [
{
count: 1,
version: v1.2.3,
},
],
}
`);
});