Files
2026-07-13 13:23:39 +08:00

343 lines
12 KiB
TypeScript

import { Configuration, EventType, Statistics } from '@crawlee/core';
import type { Dictionary } from '@crawlee/utils';
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator';
describe('Statistics', () => {
const getPerMinute = (jobCount: number, totalTickMillis: number) => {
return Math.round(jobCount / (totalTickMillis / 1000 / 60));
};
let stats: Statistics;
const localStorageEmulator = new MemoryStorageEmulator();
const events = Configuration.getEventManager();
beforeAll(async () => {
vitest.useFakeTimers();
});
beforeEach(async () => {
await localStorageEmulator.init();
stats = new Statistics();
});
afterEach(async () => {
events.off(EventType.PERSIST_STATE);
stats = null as any;
});
afterAll(async () => {
await localStorageEmulator.destroy();
// eslint-disable-next-line dot-notation
Statistics['id'] = 0;
});
describe('persist state', () => {
// needs to go first for predictability
test('should increment id by each new consecutive instance', () => {
expect(stats.id).toEqual(0);
// @ts-expect-error Accessing private prop
expect(Statistics.id).toEqual(1);
// @ts-expect-error Accessing private prop
expect(stats.persistStateKey).toEqual('SDK_CRAWLER_STATISTICS_0');
const [n1, n2] = [new Statistics(), new Statistics()];
expect(n1.id).toEqual(1);
expect(n2.id).toEqual(2);
// @ts-expect-error Accessing private prop
expect(Statistics.id).toEqual(3);
});
test('should persist the state to KV and load again', async () => {
const startedAt = 1000;
vitest.advanceTimersByTime(startedAt);
stats.startJob(0);
vitest.advanceTimersByTime(100);
stats.finishJob(0, 0);
await stats.startCapturing();
await stats.persistState();
// console.dir(stats);
// @ts-expect-error Accessing private prop
const state = await stats.keyValueStore.getValue(stats.persistStateKey);
/*
expect(state).toEqual({
crawlerFinishedAt: null,
crawlerLastStartTimestamp: 0,
crawlerRuntimeMillis: 1100,
crawlerStartedAt: toISOString(startedAt + 100),
requestAvgFailedDurationMillis: null,
requestAvgFinishedDurationMillis: 100,
requestMaxDurationMillis: 100,
requestMinDurationMillis: 100,
requestTotalDurationMillis: 100,
requestRetryHistogram: [1],
requestTotalFailedDurationMillis: 0,
requestTotalFinishedDurationMillis: 100,
requestsFailed: 0,
requestsFailedPerMinute: 0,
requestsFinished: 1,
requestsFinishedPerMinute: 55,
requestsRetries: 0,
requestsTotal: 1,
statsId: stats.id,
statsPersistedAt: toISOString(startedAt + 100),
});
await stats.stopCapturing();
stats.reset();
expect(stats.toJSON()).toEqual({
crawlerFinishedAt: null,
crawlerRuntimeMillis: 0,
crawlerLastStartTimestamp: 1100,
crawlerStartedAt: null,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: Infinity,
requestMaxDurationMillis: 0,
requestMinDurationMillis: Infinity,
requestRetryHistogram: [],
requestTotalDurationMillis: 0,
requestTotalFailedDurationMillis: 0,
requestTotalFinishedDurationMillis: 0,
requestsFailed: 0,
requestsFailedPerMinute: 0,
requestsFinished: 0,
requestsFinishedPerMinute: 0,
requestsRetries: 0,
requestsTotal: 0,
statsId: stats.id,
statsPersistedAt: toISOString(startedAt + 100),
});
await stats.startCapturing();
stats.startJob(1);
vitest.advanceTimersByTime(100);
stats.finishJob(1);
vitest.advanceTimersByTime(1000);
expect(stats.toJSON()).toEqual({
crawlerRuntimeMillis: 2200,
crawlerLastStartTimestamp: 0,
crawlerFinishedAt: toISOString(startedAt + 100),
crawlerStartedAt: toISOString(startedAt + 100),
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: 100,
requestMaxDurationMillis: 100,
requestMinDurationMillis: 100,
requestRetryHistogram: [2],
requestTotalDurationMillis: 200,
requestTotalFailedDurationMillis: 0,
requestTotalFinishedDurationMillis: 200,
requestsFailed: 0,
requestsFailedPerMinute: 0,
requestsFinished: 2,
requestsFinishedPerMinute: 55,
requestsRetries: 0,
requestsTotal: 2,
statsId: stats.id,
statsPersistedAt: toISOString(startedAt + 1200),
});
vitest.advanceTimersByTime(10000);
expect(stats.calculate()).toEqual({
crawlerRuntimeMillis: 12200,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: 100,
requestTotalDurationMillis: 200,
requestsFailedPerMinute: 0,
requestsFinishedPerMinute: getPerMinute(2, 12200),
requestsTotal: 2,
});
*/
});
test('should remove persist state event listener', async () => {
await stats.startCapturing();
expect(events.listenerCount(EventType.PERSIST_STATE)).toEqual(1);
await stats.stopCapturing();
expect(events.listenerCount(EventType.PERSIST_STATE)).toEqual(0);
await stats.startCapturing();
expect(events.listenerCount(EventType.PERSIST_STATE)).toEqual(1);
stats.reset();
expect(events.listenerCount(EventType.PERSIST_STATE)).toEqual(0);
});
test('on persistState event', async () => {
stats.startJob(0);
vitest.advanceTimersByTime(100);
stats.finishJob(0, 0);
await stats.startCapturing(); // keyValueStore is initialized here
const state = stats.toJSON();
// @ts-expect-error Accessing private prop
const setValueSpy = vitest.spyOn(stats.keyValueStore, 'setValue');
events.emit(EventType.PERSIST_STATE);
// TODO: these properties don't exist on the calculate return type
// @ts-expect-error Incorrect types?
const { retryHistogram, finished, failed, ...rest } = stats.calculate();
expect(setValueSpy).toBeCalledWith(
// @ts-expect-error Accessing private prop
stats.persistStateKey,
{ ...state, ...rest },
{
doNotRetryTimeouts: true,
timeoutSecs: 30,
},
);
}, 2000);
});
test('should finish a job', () => {
stats.startJob(0);
vitest.advanceTimersByTime(1);
stats.finishJob(0, 0);
vitest.advanceTimersByTime(1);
const current = stats.calculate();
expect(current).toEqual({
crawlerRuntimeMillis: 2,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: 1,
requestTotalDurationMillis: 1,
requestsFailedPerMinute: 0,
requestsFinishedPerMinute: getPerMinute(1, 2),
requestsTotal: 1,
});
});
test('should fail a job', () => {
stats.startJob(0);
vitest.advanceTimersByTime(0);
stats.failJob(0, 0);
vitest.advanceTimersByTime(1);
const current = stats.calculate();
expect(current).toEqual({
crawlerRuntimeMillis: 1,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: Infinity,
requestTotalDurationMillis: 0,
requestsFailedPerMinute: 60000,
requestsFinishedPerMinute: 0,
requestsTotal: 1,
});
expect(stats.requestRetryHistogram).toEqual([1]);
});
test('should collect retries', () => {
stats.startJob(0);
stats.startJob(1);
stats.startJob(2);
stats.finishJob(0, 0);
stats.finishJob(1, 1);
stats.finishJob(2, 2);
const current = stats.calculate();
expect(current).toEqual({
crawlerRuntimeMillis: 0,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: Infinity,
requestTotalDurationMillis: 0,
requestsFailedPerMinute: 0,
requestsFinishedPerMinute: Infinity,
requestsTotal: 3,
});
expect(stats.requestRetryHistogram).toEqual([1, 1, 1]);
});
test('should return correct stats for multiple parallel jobs', () => {
stats.startJob(0);
vitest.advanceTimersByTime(1);
stats.startJob(1);
vitest.advanceTimersByTime(1);
stats.startJob(2);
vitest.advanceTimersByTime(2);
stats.finishJob(1, 0); // runtime: 3ms
vitest.advanceTimersByTime(1); // since startedAt: 5ms
stats.failJob(0, 0); // runtime: irrelevant
vitest.advanceTimersByTime(10);
stats.finishJob(2, 0); // runtime: 13ms
vitest.advanceTimersByTime(10); // since startedAt: 25ms
const current = stats.calculate();
expect(current).toEqual({
crawlerRuntimeMillis: 25,
requestAvgFailedDurationMillis: 5,
requestAvgFinishedDurationMillis: (13 + 3) / 2,
requestTotalDurationMillis: 21,
requestsFailedPerMinute: 2400,
requestsFinishedPerMinute: getPerMinute(2, 25),
requestsTotal: 3,
});
expect(stats.state).toMatchObject({
requestsFailed: 1,
requestsFinished: 2,
});
expect(stats.requestRetryHistogram).toEqual([3]);
});
test('should regularly log stats', async () => {
const logged: [string, Dictionary | undefined | null][] = [];
// @ts-expect-error Accessing private prop
const infoSpy = vitest.spyOn(stats.log, 'info');
infoSpy.mockImplementation((message: string, data?: Record<string, any> | null) => {
logged.push([message, data]);
});
stats.startJob(0);
vitest.advanceTimersByTime(1);
stats.finishJob(0, 0);
await stats.startCapturing();
vitest.advanceTimersByTime(50000);
expect(logged).toHaveLength(0);
vitest.advanceTimersByTime(10001);
expect(logged).toHaveLength(1);
expect(logged[0][0]).toBe('Statistics');
expect(logged[0][1]).toEqual({
crawlerRuntimeMillis: 60001,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: 1,
requestTotalDurationMillis: 1,
requestsFailedPerMinute: 0,
requestsFinishedPerMinute: 1,
requestsTotal: 1,
retryHistogram: [1],
});
await stats.stopCapturing();
vitest.advanceTimersByTime(60001);
expect(logged).toHaveLength(1);
expect(logged[0][0]).toBe('Statistics');
expect(logged[0][1]).toEqual({
crawlerRuntimeMillis: 60001,
requestAvgFailedDurationMillis: Infinity,
requestAvgFinishedDurationMillis: 1,
requestTotalDurationMillis: 1,
requestsFailedPerMinute: 0,
requestsFinishedPerMinute: 1,
requestsTotal: 1,
retryHistogram: [1],
});
});
test('should reset stats', async () => {
await stats.startCapturing();
stats.startJob(1);
vitest.advanceTimersByTime(3);
stats.finishJob(1, 0);
expect(stats.state.requestsFinished).toEqual(1);
expect(stats.requestRetryHistogram).toEqual([1]);
stats.reset();
expect(stats.state.requestsFinished).toEqual(0);
expect(stats.requestRetryHistogram).toEqual([]);
});
});