console/packages/libraries/core/tests/usage.spec.ts

759 lines
18 KiB
TypeScript

import { buildSchema, parse } from 'graphql';
// eslint-disable-next-line import/no-extraneous-dependencies
import nock from 'nock';
import { createHive } from '../src/client/client';
import { atLeastOnceSampler } from '../src/client/samplers';
import type { Report } from '../src/client/usage';
import { version } from '../src/version';
import { waitFor } from './test-utils';
const headers = {
'Content-Type': 'application/json',
'graphql-client-name': 'Hive Client',
'graphql-client-version': version,
};
const schema = buildSchema(/* GraphQL */ `
type Query {
project(selector: ProjectSelectorInput!): Project
projectsByType(type: ProjectType!): [Project!]!
projects(filter: FilterInput): [Project!]!
}
type Mutation {
deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload!
}
input ProjectSelectorInput {
organization: ID!
project: ID!
}
input FilterInput {
type: ProjectType
pagination: PaginationInput
}
input PaginationInput {
limit: Int
offset: Int
}
type ProjectSelector {
organization: ID!
project: ID!
}
type DeleteProjectPayload {
selector: ProjectSelector!
deletedProject: Project!
}
type Project {
id: ID!
cleanId: ID!
name: String!
type: ProjectType!
buildUrl: String
validationUrl: String
}
enum ProjectType {
FEDERATION
STITCHING
SINGLE
CUSTOM
}
`);
const op = parse(/* GraphQL */ `
mutation deleteProject($selector: ProjectSelectorInput!) {
deleteProject(selector: $selector) {
selector {
organization
project
}
deletedProject {
...ProjectFields
}
}
}
fragment ProjectFields on Project {
id
cleanId
name
type
}
`);
const op2 = parse(/* GraphQL */ `
query getProject($selector: ProjectSelectorInput!) {
project(selector: $selector) {
...ProjectFields
}
}
fragment ProjectFields on Project {
id
cleanId
name
type
}
`);
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
nock.cleanAll();
});
test('should send data to Hive', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const token = 'Token';
let report: Report = {
size: 0,
map: {},
operations: [],
};
const http = nock('http://localhost')
.post('/200')
.matchHeader('Authorization', `Bearer ${token}`)
.matchHeader('Content-Type', headers['Content-Type'])
.matchHeader('graphql-client-name', headers['graphql-client-name'])
.matchHeader('graphql-client-version', headers['graphql-client-version'])
.once()
.reply((_, _body) => {
report = _body as any;
return [200];
});
const hive = createHive({
enabled: true,
debug: true,
agent: {
timeout: 500,
maxRetries: 0,
sendInterval: 10,
logger,
},
token,
selfHosting: {
graphqlEndpoint: 'http://localhost/graphql',
applicationUrl: 'http://localhost/',
usageEndpoint: 'http://localhost/200',
},
usage: true,
});
const collect = hive.collectUsage();
await waitFor(20);
await collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
);
await hive.dispose();
await waitFor(30);
http.done();
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
// Map
expect(report.size).toEqual(1);
expect(Object.keys(report.map)).toHaveLength(1);
const key = Object.keys(report.map)[0];
const record = report.map[key];
// operation
expect(record.operation).toMatch('mutation deleteProject');
expect(record.operationName).toMatch('deleteProject');
// fields
expect(record.fields).toMatchInlineSnapshot(`
[
Mutation.deleteProject,
Mutation.deleteProject.selector,
DeleteProjectPayload.selector,
ProjectSelector.organization,
ProjectSelector.project,
DeleteProjectPayload.deletedProject,
Project.id,
Project.cleanId,
Project.name,
Project.type,
ProjectSelectorInput.organization,
ID,
ProjectSelectorInput.project,
]
`);
// Operations
const operations = report.operations;
expect(operations).toHaveLength(1); // one operation
if (!operations?.length) {
throw new Error('Expected operations to be an array');
}
const operation = operations[0];
expect(operation.operationMapKey).toEqual(key);
expect(operation.timestamp).toEqual(expect.any(Number));
// execution
expect(operation.execution.duration).toBeGreaterThanOrEqual(18 * 1_000_000); // >=18ms in microseconds
expect(operation.execution.duration).toBeLessThan(25 * 1_000_000); // <25ms
expect(operation.execution.errorsTotal).toBe(0);
expect(operation.execution.ok).toBe(true);
});
test('should send data to Hive (deprecated endpoint)', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const token = 'Token';
let report: Report = {
size: 0,
map: {},
operations: [],
};
const http = nock('http://localhost')
.post('/200')
.matchHeader('Authorization', `Bearer ${token}`)
.matchHeader('Content-Type', headers['Content-Type'])
.matchHeader('graphql-client-name', headers['graphql-client-name'])
.matchHeader('graphql-client-version', headers['graphql-client-version'])
.once()
.reply((_, _body) => {
report = _body as any;
return [200];
});
const hive = createHive({
enabled: true,
debug: true,
agent: {
timeout: 500,
maxRetries: 0,
sendInterval: 10,
logger,
},
token,
usage: {
endpoint: 'http://localhost/200',
},
});
const collect = hive.collectUsage();
await waitFor(20);
await collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
);
await hive.dispose();
await waitFor(50);
http.done();
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
// Map
expect(report.size).toEqual(1);
expect(Object.keys(report.map)).toHaveLength(1);
const key = Object.keys(report.map)[0];
const record = report.map[key];
// operation
expect(record.operation).toMatch('mutation deleteProject');
expect(record.operationName).toMatch('deleteProject');
// fields
expect(record.fields).toMatchInlineSnapshot(`
[
Mutation.deleteProject,
Mutation.deleteProject.selector,
DeleteProjectPayload.selector,
ProjectSelector.organization,
ProjectSelector.project,
DeleteProjectPayload.deletedProject,
Project.id,
Project.cleanId,
Project.name,
Project.type,
ProjectSelectorInput.organization,
ID,
ProjectSelectorInput.project,
]
`);
// Operations
const operations = report.operations;
expect(operations).toHaveLength(1); // one operation
if (!operations?.length) {
throw new Error('Expected operations to be an array');
}
const operation = operations[0];
expect(operation.operationMapKey).toEqual(key);
expect(operation.timestamp).toEqual(expect.any(Number));
// execution
expect(operation.execution.duration).toBeGreaterThanOrEqual(18 * 1_000_000); // >=18ms in microseconds
expect(operation.execution.duration).toBeLessThan(25 * 1_000_000); // <25ms
expect(operation.execution.errorsTotal).toBe(0);
expect(operation.execution.ok).toBe(true);
});
test('should not leak the exception', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const hive = createHive({
enabled: true,
debug: true,
agent: {
timeout: 500,
maxRetries: 1,
sendInterval: 10,
minTimeout: 10,
logger,
},
token: 'Token',
usage: {
endpoint: 'http://404.localhost',
},
});
await hive.collectUsage()(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
);
await waitFor(50);
await hive.dispose();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(`[hive][usage] Attempt 1 failed:`),
);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 2)`);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(`[hive][usage] Failed to send data`),
);
});
test('sendImmediately should not stop the schedule', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const token = 'Token';
const http = nock('http://localhost')
.post('/200')
.matchHeader('authorization', `Bearer ${token}`)
.matchHeader('Content-Type', headers['Content-Type'])
.matchHeader('graphql-client-name', headers['graphql-client-name'])
.matchHeader('graphql-client-version', headers['graphql-client-version'])
.times(3)
.reply((_, _body) => {
return [200];
});
const hive = createHive({
enabled: true,
debug: true,
printTokenInfo: false,
agent: {
timeout: 500,
maxRetries: 0,
maxSize: 2,
minTimeout: 10,
logger,
sendInterval: 50,
},
token,
usage: {
endpoint: 'http://localhost/200',
},
});
const args = {
schema,
document: op,
operationName: 'deleteProject',
};
const collect = hive.collectUsage();
expect(logger.info).toHaveBeenCalledTimes(0);
await collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
);
await waitFor(120);
// Because maxSize is 2 and sendInterval is 50ms (+120ms buffer)
// the scheduled send task should be done by now
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
// since we sent only 1 element, the buffer was not full,
// so we should not see the following log:
expect(logger.info).not.toHaveBeenCalledWith(`[hive][usage] Sending immediately`);
expect(logger.info).toHaveBeenCalledTimes(2);
// Now we will hit the maxSize
// We run collect two times
await Promise.all([collect(args, {}), collect(args, {})]);
await waitFor(1);
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledTimes(4);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 2) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending immediately`);
// we run setImmediate under the hood
// It should be sent already
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
expect(logger.info).toHaveBeenCalledTimes(4);
await waitFor(100);
expect(logger.info).toHaveBeenCalledTimes(5);
// Let's check if the scheduled send task is still running
await collect(args, {});
await waitFor(30);
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 1) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
await hive.dispose();
http.done();
});
test('should send data to Hive at least once when using atLeastOnceSampler', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const token = 'Token';
let report: Report = {
size: 0,
map: {},
operations: [],
};
const http = nock('http://localhost')
.post('/200')
.matchHeader('Authorization', `Bearer ${token}`)
.matchHeader('Content-Type', headers['Content-Type'])
.matchHeader('graphql-client-name', headers['graphql-client-name'])
.matchHeader('graphql-client-version', headers['graphql-client-version'])
.once()
.reply((_, _body) => {
report = _body as any;
return [200];
});
const hive = createHive({
enabled: true,
debug: true,
printTokenInfo: false,
agent: {
timeout: 500,
sendInterval: 10,
maxRetries: 0,
logger,
},
token,
selfHosting: {
graphqlEndpoint: 'http://localhost/graphql',
applicationUrl: 'http://localhost/',
usageEndpoint: 'http://localhost/200',
},
usage: {
sampler: atLeastOnceSampler({
keyFn(ctx) {
return ctx.operationName;
},
sampler() {
// only
return 0;
},
}),
},
});
const collect = hive.collectUsage();
await Promise.all([
collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
),
// different query
collect(
{
schema,
document: op2,
operationName: 'getProject',
},
{},
),
// duplicated call
collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
),
]);
await hive.dispose();
await waitFor(50);
http.done();
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 2) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
// Map
expect(report.size).toEqual(2);
expect(Object.keys(report.map)).toHaveLength(2);
const foundRecords: string[] = [];
for (const key in report.map) {
const record = report.map[key];
foundRecords.push(record.operationName ?? 'anonymous');
}
expect(foundRecords).toContainEqual('deleteProject');
expect(foundRecords).toContainEqual('getProject');
const operations = report.operations;
expect(operations).toHaveLength(2); // two operations
});
test('should not send excluded operation name data to Hive', async () => {
const logger = {
error: vi.fn(),
info: vi.fn(),
};
const token = 'Token';
let report: Report = {
size: 0,
map: {},
operations: [],
};
const http = nock('http://localhost')
.post('/200')
.once()
.reply((_, _body) => {
report = _body as any;
return [200];
});
const hive = createHive({
enabled: true,
debug: true,
agent: {
timeout: 500,
maxRetries: 0,
sendInterval: 10,
logger,
},
token,
selfHosting: {
graphqlEndpoint: 'http://localhost/graphql',
applicationUrl: 'http://localhost/',
usageEndpoint: 'http://localhost/200',
},
usage: {
exclude: ['deleteProjectShouldntBeIncluded', new RegExp('ExcludeThis$')],
},
});
const collect = hive.collectUsage();
await waitFor(20);
await Promise.all([
(collect(
{
schema,
document: op,
operationName: 'deleteProjectExcludeThis',
},
{},
),
collect(
{
schema,
document: op,
operationName: 'deleteProjectShouldntBeIncluded',
},
{},
),
collect(
{
schema,
document: op,
operationName: 'deleteProject',
},
{},
),
collect(
{
schema,
document: op2,
operationName: 'getProject',
},
{},
)),
]);
await hive.dispose();
await waitFor(50);
http.done();
expect(logger.error).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sending (queue 2) (attempt 1)`);
expect(logger.info).toHaveBeenCalledWith(`[hive][usage] Sent!`);
// Map
expect(report.size).toEqual(2);
expect(Object.keys(report.map)).toHaveLength(2);
const key = Object.keys(report.map)[0];
const record = report.map[key];
// operation
expect(record.operation).toMatch('mutation deleteProject');
expect(record.operationName).toMatch('deleteProject');
// fields
expect(record.fields).toMatchInlineSnapshot(`
[
Mutation.deleteProject,
Mutation.deleteProject.selector,
DeleteProjectPayload.selector,
ProjectSelector.organization,
ProjectSelector.project,
DeleteProjectPayload.deletedProject,
Project.id,
Project.cleanId,
Project.name,
Project.type,
ProjectSelectorInput.organization,
ID,
ProjectSelectorInput.project,
]
`);
// Operations
const operations = report.operations;
expect(operations).toHaveLength(2); // two operations
if (!operations?.length) {
throw new Error('Expected operations to be an array');
}
const operation = operations[0];
expect(operation.operationMapKey).toEqual(key);
expect(operation.timestamp).toEqual(expect.any(Number));
// execution
expect(operation.execution.duration).toBeGreaterThanOrEqual(18 * 1_000_000); // >=18ms in microseconds
expect(operation.execution.duration).toBeLessThan(25 * 1_000_000); // <25ms
expect(operation.execution.errorsTotal).toBe(0);
expect(operation.execution.ok).toBe(true);
});
test('retry on non-200', async () => {
const logSpy = vi.fn();
const logger = {
error: logSpy,
info: logSpy,
};
const token = 'Token';
const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => {
return new Response('No no no', { status: 500, statusText: 'Internal server error' });
});
const hive = createHive({
enabled: true,
debug: true,
printTokenInfo: false,
agent: {
logger,
timeout: 10,
minTimeout: 10,
sendInterval: 10,
maxRetries: 1,
__testing: {
fetch: fetchSpy,
},
},
token,
usage: {
endpoint: 'http://localhost/200',
},
reporting: false,
});
const collect = hive.collectUsage();
await collect(
{
schema,
document: op,
operationName: 'asd',
},
{},
);
await waitFor(50);
await hive.dispose();
expect(logSpy).toHaveBeenCalledWith('[hive][usage] Sending (queue 1) (attempt 1)');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`[hive][usage] Attempt 1 failed`));
expect(logSpy).toHaveBeenCalledWith('[hive][usage] Sending (queue 1) (attempt 2)');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(`[hive][usage] Attempt 2 failed`));
expect(logSpy).not.toHaveBeenCalledWith('[hive][usage] Sending (queue 1) (attempt 3)');
});