@crawlee/core 4.0.0-beta.119 → 4.0.0-beta.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/crawlers/statistics.d.ts +57 -23
- package/crawlers/statistics.js +195 -117
- package/package.json +6 -6
package/crawlers/statistics.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CrawleeLogger } from '../log.js';
|
|
2
|
-
import { KeyValueStore } from '../storages/key_value_store.js';
|
|
2
|
+
import type { KeyValueStore } from '../storages/key_value_store.js';
|
|
3
3
|
import { ErrorTracker } from './error_tracker.js';
|
|
4
4
|
/**
|
|
5
5
|
* Persistence-related options to control how and when crawler's data gets persisted.
|
|
@@ -51,7 +51,7 @@ export interface IStatistics {
|
|
|
51
51
|
* Persists the current state to the key-value store. Optional - the crawler calls it on migration, but a backend
|
|
52
52
|
* with no persistence of its own can omit it.
|
|
53
53
|
*/
|
|
54
|
-
persistState?(
|
|
54
|
+
persistState?(): Promise<void>;
|
|
55
55
|
}
|
|
56
56
|
/** The derived aggregates computed by {@link IStatistics.calculate} from the current {@link StatisticState}. */
|
|
57
57
|
export interface CalculatedStatistics {
|
|
@@ -95,31 +95,39 @@ export declare class Statistics implements IStatistics {
|
|
|
95
95
|
* Statistic instance id.
|
|
96
96
|
*/
|
|
97
97
|
readonly id: string;
|
|
98
|
+
protected readonly persistStateKey: string;
|
|
99
|
+
private readonly log;
|
|
98
100
|
/**
|
|
99
101
|
* Current statistic state used for doing calculations on {@link Statistics.calculate} calls
|
|
100
102
|
*/
|
|
101
|
-
state: StatisticState;
|
|
103
|
+
get state(): StatisticState;
|
|
102
104
|
/**
|
|
103
105
|
* Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
|
|
104
106
|
*/
|
|
105
|
-
|
|
106
|
-
protected keyValueStore?: KeyValueStore;
|
|
107
|
-
protected readonly persistStateKey: string;
|
|
108
|
-
private readonly log;
|
|
109
|
-
private get events();
|
|
107
|
+
get requestRetryHistogram(): number[];
|
|
110
108
|
/**
|
|
111
109
|
* Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
|
|
112
110
|
* persistence or error snapshots, share it across sequential runs, or subclass it to track extra fields.
|
|
113
111
|
*/
|
|
114
112
|
constructor(options?: StatisticsOptions);
|
|
115
113
|
/**
|
|
116
|
-
* Set the current statistic instance to pristine values
|
|
114
|
+
* Set the current statistic instance to pristine values.
|
|
115
|
+
*
|
|
116
|
+
* The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
|
|
117
117
|
*/
|
|
118
118
|
reset(): void;
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
120
|
+
* The pristine state a new instance starts with and {@link Statistics.reset} restores.
|
|
121
|
+
*
|
|
122
|
+
* A subclass tracking extra fields declares their initial values here.
|
|
123
|
+
*/
|
|
124
|
+
protected defaultState(): StatisticState;
|
|
125
|
+
/**
|
|
126
|
+
* Clear the persisted statistics record, leaving the in-memory state alone.
|
|
127
|
+
*
|
|
128
|
+
* Throws while capturing - the next PERSIST_STATE event would write the record straight back.
|
|
121
129
|
*/
|
|
122
|
-
resetStore(
|
|
130
|
+
resetStore(): Promise<void>;
|
|
123
131
|
/**
|
|
124
132
|
* Increments the status code counter.
|
|
125
133
|
*/
|
|
@@ -160,15 +168,23 @@ export declare class Statistics implements IStatistics {
|
|
|
160
168
|
stopCapturing(): Promise<void>;
|
|
161
169
|
private saveRetryCountForJob;
|
|
162
170
|
/**
|
|
163
|
-
* Persist internal state to the key value store
|
|
164
|
-
*
|
|
171
|
+
* Persist internal state to the key value store.
|
|
172
|
+
*
|
|
173
|
+
* Statistics are bookkeeping - a store that refuses the write is worth a warning, not a failed crawl. The
|
|
174
|
+
* crawler calls this from its migration handler, where a rejection would go unhandled.
|
|
175
|
+
*/
|
|
176
|
+
persistState(): Promise<void>;
|
|
177
|
+
/**
|
|
178
|
+
* Rebuilds the state from a persisted record.
|
|
179
|
+
*
|
|
180
|
+
* A subclass tracking extra fields restores them here, on top of the result of `super.deserializeState()`.
|
|
165
181
|
*/
|
|
166
|
-
|
|
182
|
+
protected deserializeState(persistedState: StatisticPersistedState): StatisticState;
|
|
167
183
|
/**
|
|
168
|
-
*
|
|
184
|
+
* Builds the record written to the key value store, merging in the derived aggregates so that a consumer
|
|
185
|
+
* reading the record does not have to reconstruct them.
|
|
169
186
|
*/
|
|
170
|
-
protected
|
|
171
|
-
private teardown;
|
|
187
|
+
protected serializeState(state: StatisticState): StatisticPersistedState;
|
|
172
188
|
/**
|
|
173
189
|
* Make this class serializable when called with `JSON.stringify(statsInstance)` directly
|
|
174
190
|
* or through `keyValueStore.setValue('KEY', statsInstance)`
|
|
@@ -219,17 +235,28 @@ export interface StatisticsOptions {
|
|
|
219
235
|
id?: string;
|
|
220
236
|
}
|
|
221
237
|
/**
|
|
222
|
-
* Format of the persisted stats
|
|
238
|
+
* Format of the persisted stats.
|
|
239
|
+
*
|
|
240
|
+
* The `null`s are `Infinity` on the way out - JSON has no infinity, so a record written before anything
|
|
241
|
+
* finished or failed carries a `null` in its place.
|
|
223
242
|
*/
|
|
224
|
-
export interface StatisticPersistedState extends Omit<StatisticState, 'statsPersistedAt'> {
|
|
225
|
-
requestRetryHistogram: number[];
|
|
243
|
+
export interface StatisticPersistedState extends Omit<StatisticState, 'statsPersistedAt' | 'crawlerStartedAt' | 'crawlerFinishedAt' | 'requestMinDurationMillis' | 'requestsFailedPerMinute' | 'requestsFinishedPerMinute' | 'requestRetryHistogram' | 'instanceStart'> {
|
|
226
244
|
statsId: string;
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
requestsFailedPerMinute: number | null;
|
|
246
|
+
requestsFinishedPerMinute: number | null;
|
|
247
|
+
/** ISO strings - the live state keeps these as `Date`s. */
|
|
248
|
+
crawlerStartedAt: string | null;
|
|
249
|
+
crawlerFinishedAt: string | null;
|
|
250
|
+
statsPersistedAt: string;
|
|
251
|
+
requestMinDurationMillis: number | null;
|
|
252
|
+
/** A retry count that no request ever reached leaves a `null` here. */
|
|
253
|
+
requestRetryHistogram: (number | null)[];
|
|
254
|
+
requestAvgFailedDurationMillis: number | null;
|
|
255
|
+
requestAvgFinishedDurationMillis: number | null;
|
|
229
256
|
requestTotalDurationMillis: number;
|
|
230
257
|
requestsTotal: number;
|
|
258
|
+
/** {@link StatisticState.instanceStart} of the run that wrote the record. */
|
|
231
259
|
crawlerLastStartTimestamp: number;
|
|
232
|
-
statsPersistedAt: string;
|
|
233
260
|
}
|
|
234
261
|
/**
|
|
235
262
|
* Contains the statistics state
|
|
@@ -251,4 +278,11 @@ export interface StatisticState {
|
|
|
251
278
|
errors: Record<string, unknown>;
|
|
252
279
|
retryErrors: Record<string, unknown>;
|
|
253
280
|
requestsWithStatusCode: Record<string, number>;
|
|
281
|
+
/** Retries histogram - index `i` holds the number of requests that finished after `i` retries. */
|
|
282
|
+
requestRetryHistogram: number[];
|
|
283
|
+
/**
|
|
284
|
+
* When the current capture window started, as a `Date.now()` timestamp. Rebased on load so that the runtime
|
|
285
|
+
* reported by {@link Statistics.calculate} spans a migration rather than restarting from zero.
|
|
286
|
+
*/
|
|
287
|
+
instanceStart: number;
|
|
254
288
|
}
|
package/crawlers/statistics.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ow from 'ow';
|
|
2
|
-
import {
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { RecoverableState } from '../recoverable_state.js';
|
|
3
4
|
import { serviceLocator } from '../service_locator.js';
|
|
4
|
-
import { KeyValueStore } from '../storages/key_value_store.js';
|
|
5
5
|
import { ErrorTracker } from './error_tracker.js';
|
|
6
6
|
/**
|
|
7
7
|
* @ignore
|
|
@@ -25,6 +25,115 @@ const errorTrackerConfig = {
|
|
|
25
25
|
showErrorMessage: true,
|
|
26
26
|
showFullMessage: false,
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* The persisted record, in the order it is written - the schema rebuilds the object on the way out, so the field
|
|
30
|
+
* order here *is* the record's field order (guarded by a test).
|
|
31
|
+
*
|
|
32
|
+
* JSON has no infinity, so the three fields that are `Infinity` until the first request settles are written as
|
|
33
|
+
* `null`. Both {@link Statistics.serializeState} and {@link Statistics.deserializeState} run through this,
|
|
34
|
+
* which is what keeps them describing the same record.
|
|
35
|
+
*
|
|
36
|
+
* Nothing is optional on purpose: `serializeState` has always written every field, so a record missing one is not
|
|
37
|
+
* one of ours and is discarded whole rather than partially trusted - a counter restored as a string would poison
|
|
38
|
+
* every later increment.
|
|
39
|
+
*/
|
|
40
|
+
const persistedStatisticState = z
|
|
41
|
+
.object({
|
|
42
|
+
requestsFinished: z.number(),
|
|
43
|
+
requestsFailed: z.number(),
|
|
44
|
+
requestsRetries: z.number(),
|
|
45
|
+
requestsFailedPerMinute: z.number().nullable(),
|
|
46
|
+
requestsFinishedPerMinute: z.number().nullable(),
|
|
47
|
+
requestMinDurationMillis: z.number().nullable(),
|
|
48
|
+
requestMaxDurationMillis: z.number(),
|
|
49
|
+
requestTotalFailedDurationMillis: z.number(),
|
|
50
|
+
requestTotalFinishedDurationMillis: z.number(),
|
|
51
|
+
crawlerStartedAt: z.string().nullable(),
|
|
52
|
+
crawlerFinishedAt: z.string().nullable(),
|
|
53
|
+
statsPersistedAt: z.string(),
|
|
54
|
+
crawlerRuntimeMillis: z.number(),
|
|
55
|
+
crawlerLastStartTimestamp: z.number(),
|
|
56
|
+
// A retry count that never occurred leaves a hole in the live histogram, written out as a `null`. We
|
|
57
|
+
// once saw a record whose histogram was not an array at all and crashed the crawler on load.
|
|
58
|
+
requestRetryHistogram: z.array(z.number().nullable()),
|
|
59
|
+
statsId: z.string(),
|
|
60
|
+
requestAvgFailedDurationMillis: z.number().nullable(),
|
|
61
|
+
requestAvgFinishedDurationMillis: z.number().nullable(),
|
|
62
|
+
requestTotalDurationMillis: z.number(),
|
|
63
|
+
requestsTotal: z.number(),
|
|
64
|
+
requestsWithStatusCode: z.record(z.string(), z.number()),
|
|
65
|
+
errors: z.record(z.string(), z.unknown()),
|
|
66
|
+
retryErrors: z.record(z.string(), z.unknown()),
|
|
67
|
+
})
|
|
68
|
+
// A subclass tracking extra fields spreads them into the record; they are none of this schema's business,
|
|
69
|
+
// but they must not be dropped on the way through it.
|
|
70
|
+
.catchall(z.unknown());
|
|
71
|
+
/** `Infinity` is what the statistics use for "nothing to average yet"; JSON has only `null` for it. */
|
|
72
|
+
function finiteOrNull(value) {
|
|
73
|
+
return Number.isFinite(value) ? value : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The conversion between the live state and the record above, in both directions.
|
|
77
|
+
*
|
|
78
|
+
* Built per instance rather than kept as a constant because a record carries three things the state does not: the
|
|
79
|
+
* instance `id`, the derived aggregates of the overridable {@link Statistics.calculate}, and - on the way back -
|
|
80
|
+
* the fields that are rebuilt from {@link Statistics.defaultState} rather than restored, the error trackers
|
|
81
|
+
* among them.
|
|
82
|
+
*
|
|
83
|
+
* The model side is deliberately opaque: zod rebuilds what it validates, and `state.errors` has to stay the very
|
|
84
|
+
* object the error trackers write into, not a copy of it.
|
|
85
|
+
*/
|
|
86
|
+
function buildStatisticStateCodec(statistics) {
|
|
87
|
+
return z.codec(persistedStatisticState, z.custom(), {
|
|
88
|
+
decode: (record) => ({
|
|
89
|
+
...statistics.defaultState(),
|
|
90
|
+
requestsFinished: record.requestsFinished,
|
|
91
|
+
requestsFailed: record.requestsFailed,
|
|
92
|
+
requestsRetries: record.requestsRetries,
|
|
93
|
+
requestTotalFailedDurationMillis: record.requestTotalFailedDurationMillis,
|
|
94
|
+
requestTotalFinishedDurationMillis: record.requestTotalFinishedDurationMillis,
|
|
95
|
+
// Restoring the `null` as-is would make every later `duration < min` comparison fail, leaving the
|
|
96
|
+
// minimum `null` for the rest of the run.
|
|
97
|
+
requestMinDurationMillis: record.requestMinDurationMillis ?? Infinity,
|
|
98
|
+
requestMaxDurationMillis: record.requestMaxDurationMillis,
|
|
99
|
+
crawlerRuntimeMillis: record.crawlerRuntimeMillis,
|
|
100
|
+
// A `null` stands for the zero requests that reached that retry count - restore it as such.
|
|
101
|
+
requestRetryHistogram: record.requestRetryHistogram.map((count) => count ?? 0),
|
|
102
|
+
// The record keeps ISO strings, the live state keeps `Date`s.
|
|
103
|
+
crawlerStartedAt: record.crawlerStartedAt === null ? null : new Date(record.crawlerStartedAt),
|
|
104
|
+
crawlerFinishedAt: record.crawlerFinishedAt === null ? null : new Date(record.crawlerFinishedAt),
|
|
105
|
+
statsPersistedAt: new Date(record.statsPersistedAt),
|
|
106
|
+
// Rebased so that the runtime reported by `calculate()` spans the migration instead of restarting.
|
|
107
|
+
instanceStart: Date.now() - (new Date(record.statsPersistedAt).getTime() - record.crawlerLastStartTimestamp),
|
|
108
|
+
}),
|
|
109
|
+
encode: (state) => {
|
|
110
|
+
const { requestsWithStatusCode, errors, retryErrors, requestRetryHistogram, instanceStart, ...counters } = state;
|
|
111
|
+
// Every rate and average `calculate()` derives is `Infinity` until the run is long enough, or until
|
|
112
|
+
// something has finished or failed, to divide by.
|
|
113
|
+
const { requestAvgFailedDurationMillis, requestAvgFinishedDurationMillis, requestsFailedPerMinute, requestsFinishedPerMinute, ...aggregates } = statistics.calculate();
|
|
114
|
+
return {
|
|
115
|
+
...counters,
|
|
116
|
+
requestMinDurationMillis: finiteOrNull(state.requestMinDurationMillis),
|
|
117
|
+
crawlerStartedAt: state.crawlerStartedAt ? new Date(state.crawlerStartedAt).toISOString() : null,
|
|
118
|
+
crawlerFinishedAt: state.crawlerFinishedAt ? new Date(state.crawlerFinishedAt).toISOString() : null,
|
|
119
|
+
statsPersistedAt: new Date().toISOString(),
|
|
120
|
+
crawlerLastStartTimestamp: instanceStart,
|
|
121
|
+
// `Array.from`, not `map` - a hole left by a retry count no request reached is skipped by `map`
|
|
122
|
+
// and would stay a hole, which is not something the record can carry.
|
|
123
|
+
requestRetryHistogram: Array.from(requestRetryHistogram, (count) => count ?? null),
|
|
124
|
+
statsId: statistics.statsId,
|
|
125
|
+
...aggregates,
|
|
126
|
+
requestAvgFailedDurationMillis: finiteOrNull(requestAvgFailedDurationMillis),
|
|
127
|
+
requestAvgFinishedDurationMillis: finiteOrNull(requestAvgFinishedDurationMillis),
|
|
128
|
+
requestsFailedPerMinute: finiteOrNull(requestsFailedPerMinute),
|
|
129
|
+
requestsFinishedPerMinute: finiteOrNull(requestsFinishedPerMinute),
|
|
130
|
+
requestsWithStatusCode,
|
|
131
|
+
errors,
|
|
132
|
+
retryErrors,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
28
137
|
/**
|
|
29
138
|
* The statistics class provides an interface to collecting and logging run
|
|
30
139
|
* statistics for requests.
|
|
@@ -50,30 +159,25 @@ export class Statistics {
|
|
|
50
159
|
* Statistic instance id.
|
|
51
160
|
*/
|
|
52
161
|
id;
|
|
53
|
-
/**
|
|
54
|
-
* Current statistic state used for doing calculations on {@link Statistics.calculate} calls
|
|
55
|
-
*/
|
|
56
|
-
state;
|
|
57
|
-
/**
|
|
58
|
-
* Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
|
|
59
|
-
*/
|
|
60
|
-
requestRetryHistogram = [];
|
|
61
|
-
keyValueStore = undefined;
|
|
62
162
|
persistStateKey;
|
|
163
|
+
#stateCodec;
|
|
164
|
+
#recoverableState;
|
|
63
165
|
#logIntervalMillis;
|
|
64
166
|
#logMessage;
|
|
65
|
-
#listener;
|
|
66
167
|
#requestsInProgress = new Map();
|
|
67
168
|
log;
|
|
68
|
-
#instanceStart;
|
|
69
169
|
#logInterval;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Current statistic state used for doing calculations on {@link Statistics.calculate} calls
|
|
172
|
+
*/
|
|
173
|
+
get state() {
|
|
174
|
+
return this.#recoverableState.currentValue;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
|
|
178
|
+
*/
|
|
179
|
+
get requestRetryHistogram() {
|
|
180
|
+
return this.state.requestRetryHistogram;
|
|
77
181
|
}
|
|
78
182
|
/**
|
|
79
183
|
* Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
|
|
@@ -99,19 +203,42 @@ export class Statistics {
|
|
|
99
203
|
this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
|
|
100
204
|
this.#logIntervalMillis = logIntervalSecs * 1000;
|
|
101
205
|
this.#logMessage = logMessage;
|
|
102
|
-
|
|
103
|
-
this.#
|
|
104
|
-
|
|
206
|
+
// Late-bound on purpose - both hooks are override points, and a subclass's must be the ones that run.
|
|
207
|
+
this.#stateCodec = buildStatisticStateCodec({
|
|
208
|
+
statsId: this.id,
|
|
209
|
+
defaultState: () => this.defaultState(),
|
|
210
|
+
calculate: () => this.calculate(),
|
|
211
|
+
});
|
|
212
|
+
this.#recoverableState = new RecoverableState({
|
|
213
|
+
persistStateKey: this.persistStateKey,
|
|
214
|
+
persistenceEnabled: persistenceOptions.enable,
|
|
215
|
+
keyValueStore,
|
|
216
|
+
logger: this.log,
|
|
217
|
+
defaultState: () => this.defaultState(),
|
|
218
|
+
serialize: (state) => this.serializeState(state),
|
|
219
|
+
deserialize: (persistedState) => this.deserializeState(persistedState),
|
|
220
|
+
});
|
|
105
221
|
// initialize by "resetting"
|
|
106
222
|
this.reset();
|
|
107
223
|
}
|
|
108
224
|
/**
|
|
109
|
-
* Set the current statistic instance to pristine values
|
|
225
|
+
* Set the current statistic instance to pristine values.
|
|
226
|
+
*
|
|
227
|
+
* The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
|
|
110
228
|
*/
|
|
111
229
|
reset() {
|
|
112
230
|
this.errorTracker.reset();
|
|
113
231
|
this.errorTrackerRetry.reset();
|
|
114
|
-
this.
|
|
232
|
+
this.#recoverableState.reset();
|
|
233
|
+
this.#requestsInProgress.clear();
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The pristine state a new instance starts with and {@link Statistics.reset} restores.
|
|
237
|
+
*
|
|
238
|
+
* A subclass tracking extra fields declares their initial values here.
|
|
239
|
+
*/
|
|
240
|
+
defaultState() {
|
|
241
|
+
return {
|
|
115
242
|
requestsFinished: 0,
|
|
116
243
|
requestsFailed: 0,
|
|
117
244
|
requestsRetries: 0,
|
|
@@ -126,25 +253,20 @@ export class Statistics {
|
|
|
126
253
|
statsPersistedAt: null,
|
|
127
254
|
crawlerRuntimeMillis: 0,
|
|
128
255
|
requestsWithStatusCode: {},
|
|
256
|
+
// Aliases, not copies - the trackers keep writing into these objects.
|
|
129
257
|
errors: this.errorTracker.result,
|
|
130
258
|
retryErrors: this.errorTrackerRetry.result,
|
|
259
|
+
requestRetryHistogram: [],
|
|
260
|
+
instanceStart: Date.now(),
|
|
131
261
|
};
|
|
132
|
-
this.requestRetryHistogram.length = 0;
|
|
133
|
-
this.#requestsInProgress.clear();
|
|
134
|
-
this.#instanceStart = Date.now();
|
|
135
|
-
this.teardown();
|
|
136
262
|
}
|
|
137
263
|
/**
|
|
138
|
-
*
|
|
264
|
+
* Clear the persisted statistics record, leaving the in-memory state alone.
|
|
265
|
+
*
|
|
266
|
+
* Throws while capturing - the next PERSIST_STATE event would write the record straight back.
|
|
139
267
|
*/
|
|
140
|
-
async resetStore(
|
|
141
|
-
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
if (!this.keyValueStore) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
await this.keyValueStore.setValue(this.persistStateKey, null);
|
|
268
|
+
async resetStore() {
|
|
269
|
+
await this.#recoverableState.resetStore();
|
|
148
270
|
}
|
|
149
271
|
/**
|
|
150
272
|
* Increments the status code counter.
|
|
@@ -211,7 +333,7 @@ export class Statistics {
|
|
|
211
333
|
*/
|
|
212
334
|
calculate() {
|
|
213
335
|
const { requestsFailed, requestsFinished, requestTotalFailedDurationMillis, requestTotalFinishedDurationMillis, } = this.state;
|
|
214
|
-
const totalMillis = Date.now() - this
|
|
336
|
+
const totalMillis = Date.now() - this.state.instanceStart;
|
|
215
337
|
const totalMinutes = totalMillis / 1000 / 60;
|
|
216
338
|
return {
|
|
217
339
|
requestAvgFailedDurationMillis: Math.round(requestTotalFailedDurationMillis / requestsFailed) || Infinity,
|
|
@@ -233,14 +355,11 @@ export class Statistics {
|
|
|
233
355
|
if (this.#logInterval) {
|
|
234
356
|
throw new Error('Statistics.startCapturing() was already called - this instance is already capturing.');
|
|
235
357
|
}
|
|
236
|
-
|
|
358
|
+
await this.#recoverableState.initialize();
|
|
359
|
+
// After the load, so that a restored record keeps the timestamp of the run it belongs to.
|
|
237
360
|
if (this.state.crawlerStartedAt === null) {
|
|
238
361
|
this.state.crawlerStartedAt = new Date();
|
|
239
362
|
}
|
|
240
|
-
if (this.#persistenceOptions.enable) {
|
|
241
|
-
await this.maybeLoadStatistics();
|
|
242
|
-
this.events.on(EventType.PERSIST_STATE, this.#listener);
|
|
243
|
-
}
|
|
244
363
|
this.#logInterval = setInterval(() => {
|
|
245
364
|
this.log.info(this.#logMessage, {
|
|
246
365
|
...this.calculate(),
|
|
@@ -252,9 +371,9 @@ export class Statistics {
|
|
|
252
371
|
* Stops logging and remove event listeners, then persist
|
|
253
372
|
*/
|
|
254
373
|
async stopCapturing() {
|
|
255
|
-
this
|
|
374
|
+
this.#stopLogging();
|
|
256
375
|
this.state.crawlerFinishedAt = new Date();
|
|
257
|
-
await this.
|
|
376
|
+
await this.#recoverableState.teardown();
|
|
258
377
|
}
|
|
259
378
|
saveRetryCountForJob(retryCount) {
|
|
260
379
|
if (retryCount > 0)
|
|
@@ -263,96 +382,55 @@ export class Statistics {
|
|
|
263
382
|
this.requestRetryHistogram[retryCount]++;
|
|
264
383
|
}
|
|
265
384
|
/**
|
|
266
|
-
* Persist internal state to the key value store
|
|
267
|
-
*
|
|
385
|
+
* Persist internal state to the key value store.
|
|
386
|
+
*
|
|
387
|
+
* Statistics are bookkeeping - a store that refuses the write is worth a warning, not a failed crawl. The
|
|
388
|
+
* crawler calls this from its migration handler, where a rejection would go unhandled.
|
|
268
389
|
*/
|
|
269
|
-
async persistState(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
// this might be called before startCapturing was called without using await, should not crash
|
|
274
|
-
if (!this.keyValueStore) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
this.log.debug('Persisting state', { persistStateKey: this.persistStateKey });
|
|
278
|
-
await this.keyValueStore
|
|
279
|
-
.setValue(this.persistStateKey, this.toJSON())
|
|
390
|
+
async persistState() {
|
|
391
|
+
await this.#recoverableState
|
|
392
|
+
.persistState()
|
|
280
393
|
.catch((error) => this.log.warning(`Failed to persist the statistics to ${this.persistStateKey}`, { error }));
|
|
281
394
|
}
|
|
282
395
|
/**
|
|
283
|
-
*
|
|
396
|
+
* Rebuilds the state from a persisted record.
|
|
397
|
+
*
|
|
398
|
+
* A subclass tracking extra fields restores them here, on top of the result of `super.deserializeState()`.
|
|
284
399
|
*/
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// We saw a run where the requestRetryHistogram was not iterable and crashed
|
|
294
|
-
// the crawler. Adding some logging to monitor this problem in the future.
|
|
295
|
-
if (!Array.isArray(savedState.requestRetryHistogram)) {
|
|
296
|
-
this.log.warning('Received invalid state from Key-value store.', {
|
|
400
|
+
deserializeState(persistedState) {
|
|
401
|
+
// The cast is the index signature the `catchall` puts on the schema and an interface cannot have - the
|
|
402
|
+
// record is an unvalidated blob off the key-value store either way, which is what the decode is for.
|
|
403
|
+
const restored = z.safeDecode(this.#stateCodec, persistedState);
|
|
404
|
+
if (!restored.success) {
|
|
405
|
+
// Statistics are bookkeeping - a record that cannot be made sense of is worth a warning and a fresh
|
|
406
|
+
// start, not a failed crawl.
|
|
407
|
+
this.log.warning('Received invalid state from Key-value store, starting the statistics from scratch.', {
|
|
297
408
|
persistStateKey: this.persistStateKey,
|
|
298
|
-
|
|
409
|
+
issues: restored.error.issues,
|
|
299
410
|
});
|
|
411
|
+
return this.defaultState();
|
|
300
412
|
}
|
|
301
413
|
this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.persistStateKey });
|
|
302
|
-
|
|
303
|
-
// `RangeError: Maximum call stack size exceeded` if we use `a.push(...b)`
|
|
304
|
-
savedState.requestRetryHistogram.forEach((idx) => this.requestRetryHistogram.push(idx));
|
|
305
|
-
this.state.requestsFinished = savedState.requestsFinished;
|
|
306
|
-
this.state.requestsFailed = savedState.requestsFailed;
|
|
307
|
-
this.state.requestsRetries = savedState.requestsRetries;
|
|
308
|
-
this.state.requestTotalFailedDurationMillis = savedState.requestTotalFailedDurationMillis;
|
|
309
|
-
this.state.requestTotalFinishedDurationMillis = savedState.requestTotalFinishedDurationMillis;
|
|
310
|
-
this.state.requestMinDurationMillis = savedState.requestMinDurationMillis;
|
|
311
|
-
this.state.requestMaxDurationMillis = savedState.requestMaxDurationMillis;
|
|
312
|
-
// persisted state uses ISO date strings
|
|
313
|
-
this.state.crawlerFinishedAt = savedState.crawlerFinishedAt ? new Date(savedState.crawlerFinishedAt) : null;
|
|
314
|
-
this.state.crawlerStartedAt = savedState.crawlerStartedAt ? new Date(savedState.crawlerStartedAt) : null;
|
|
315
|
-
this.state.statsPersistedAt = savedState.statsPersistedAt ? new Date(savedState.statsPersistedAt) : null;
|
|
316
|
-
this.state.crawlerRuntimeMillis = savedState.crawlerRuntimeMillis;
|
|
317
|
-
this.#instanceStart = Date.now() - (+this.state.statsPersistedAt - savedState.crawlerLastStartTimestamp);
|
|
318
|
-
this.log.debug('Loaded from KeyValueStore');
|
|
414
|
+
return { ...this.defaultState(), ...restored.data };
|
|
319
415
|
}
|
|
320
|
-
|
|
321
|
-
// this can be called before a call to startCapturing happens (or in a 'finally' block)
|
|
322
|
-
// Only unsubscribe if event manager was already resolved — avoid eagerly resolving it
|
|
323
|
-
// (e.g. during the constructor's reset() call, which would capture the wrong context)
|
|
324
|
-
this.#events?.off(EventType.PERSIST_STATE, this.#listener);
|
|
416
|
+
#stopLogging() {
|
|
325
417
|
if (this.#logInterval) {
|
|
326
418
|
clearInterval(this.#logInterval);
|
|
327
419
|
this.#logInterval = null;
|
|
328
420
|
}
|
|
329
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Builds the record written to the key value store, merging in the derived aggregates so that a consumer
|
|
424
|
+
* reading the record does not have to reconstruct them.
|
|
425
|
+
*/
|
|
426
|
+
serializeState(state) {
|
|
427
|
+
return z.encode(this.#stateCodec, state);
|
|
428
|
+
}
|
|
330
429
|
/**
|
|
331
430
|
* Make this class serializable when called with `JSON.stringify(statsInstance)` directly
|
|
332
431
|
* or through `keyValueStore.setValue('KEY', statsInstance)`
|
|
333
432
|
*/
|
|
334
433
|
toJSON() {
|
|
335
|
-
|
|
336
|
-
// without the need to reconstruct for the sake of stats.calculate()
|
|
337
|
-
// omit duplicated information
|
|
338
|
-
const result = {
|
|
339
|
-
...this.state,
|
|
340
|
-
crawlerLastStartTimestamp: this.#instanceStart,
|
|
341
|
-
crawlerFinishedAt: this.state.crawlerFinishedAt
|
|
342
|
-
? new Date(this.state.crawlerFinishedAt).toISOString()
|
|
343
|
-
: null,
|
|
344
|
-
crawlerStartedAt: this.state.crawlerStartedAt ? new Date(this.state.crawlerStartedAt).toISOString() : null,
|
|
345
|
-
requestRetryHistogram: this.requestRetryHistogram,
|
|
346
|
-
statsId: this.id,
|
|
347
|
-
statsPersistedAt: new Date().toISOString(),
|
|
348
|
-
...this.calculate(),
|
|
349
|
-
};
|
|
350
|
-
Reflect.deleteProperty(result, 'requestsWithStatusCode');
|
|
351
|
-
Reflect.deleteProperty(result, 'errors');
|
|
352
|
-
Reflect.deleteProperty(result, 'retryErrors');
|
|
353
|
-
result.requestsWithStatusCode = this.state.requestsWithStatusCode;
|
|
354
|
-
result.errors = this.state.errors;
|
|
355
|
-
result.retryErrors = this.state.retryErrors;
|
|
356
|
-
return result;
|
|
434
|
+
return this.serializeState(this.state);
|
|
357
435
|
}
|
|
358
436
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.120",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"@apify/log": "^2.5.18",
|
|
53
53
|
"@apify/timeout": "^0.4.4",
|
|
54
54
|
"@apify/utilities": "^2.15.5",
|
|
55
|
-
"@crawlee/fs-storage": "4.0.0-beta.
|
|
56
|
-
"@crawlee/types": "4.0.0-beta.
|
|
57
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
55
|
+
"@crawlee/fs-storage": "4.0.0-beta.120",
|
|
56
|
+
"@crawlee/types": "4.0.0-beta.120",
|
|
57
|
+
"@crawlee/utils": "4.0.0-beta.120",
|
|
58
58
|
"@sapphire/async-queue": "^1.5.5",
|
|
59
59
|
"@sapphire/shapeshift": "^4.0.0",
|
|
60
60
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"tough-cookie": "^6.0.0",
|
|
70
70
|
"tslib": "^2.8.1",
|
|
71
71
|
"type-fest": "^4.41.0",
|
|
72
|
-
"zod": "^
|
|
72
|
+
"zod": "^4.1.0"
|
|
73
73
|
},
|
|
74
74
|
"lerna": {
|
|
75
75
|
"command": {
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "da5d427c4c1c9dcaea95b151e9c4c7310100885d"
|
|
82
82
|
}
|