@crawlee/core 4.0.0-beta.17 → 4.0.0-beta.170

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.
Files changed (287) hide show
  1. package/README.md +14 -14
  2. package/autoscaling/autoscaled_pool.d.ts +73 -177
  3. package/autoscaling/autoscaled_pool.js +182 -329
  4. package/autoscaling/concurrency_system.d.ts +268 -0
  5. package/autoscaling/concurrency_system.js +364 -0
  6. package/autoscaling/cpu_load_signal.d.ts +43 -0
  7. package/autoscaling/cpu_load_signal.js +47 -0
  8. package/autoscaling/event_loop_load_signal.d.ts +51 -0
  9. package/autoscaling/event_loop_load_signal.js +60 -0
  10. package/autoscaling/index.d.ts +6 -1
  11. package/autoscaling/index.js +6 -1
  12. package/autoscaling/load_signal.d.ts +98 -0
  13. package/autoscaling/load_signal.js +103 -0
  14. package/autoscaling/memory_load_signal.d.ts +47 -0
  15. package/autoscaling/memory_load_signal.js +106 -0
  16. package/autoscaling/snapshotter.d.ts +58 -163
  17. package/autoscaling/snapshotter.js +45 -263
  18. package/autoscaling/storage_backend_load_signal.d.ts +56 -0
  19. package/autoscaling/storage_backend_load_signal.js +73 -0
  20. package/autoscaling/system_status.d.ts +70 -92
  21. package/autoscaling/system_status.js +92 -122
  22. package/autoscaling/weighted_avg.d.ts +5 -0
  23. package/autoscaling/weighted_avg.js +14 -0
  24. package/byte_utils.d.ts +17 -0
  25. package/byte_utils.js +42 -0
  26. package/configuration.d.ts +96 -223
  27. package/configuration.js +170 -222
  28. package/cookie_utils.d.ts +3 -2
  29. package/cookie_utils.js +18 -7
  30. package/crawlers/context_pipeline.d.ts +10 -1
  31. package/crawlers/context_pipeline.js +31 -8
  32. package/crawlers/crawler_commons.d.ts +128 -124
  33. package/crawlers/crawler_commons.js +1 -108
  34. package/crawlers/error_snapshotter.d.ts +2 -5
  35. package/crawlers/error_snapshotter.js +5 -6
  36. package/crawlers/error_tracker.d.ts +0 -1
  37. package/crawlers/error_tracker.js +0 -1
  38. package/crawlers/index.d.ts +1 -3
  39. package/crawlers/index.js +0 -3
  40. package/crawlers/internals/types.d.ts +0 -1
  41. package/crawlers/internals/types.js +0 -1
  42. package/crawlers/statistics.d.ts +194 -79
  43. package/crawlers/statistics.js +382 -201
  44. package/debug.d.ts +36 -0
  45. package/debug.js +70 -0
  46. package/enqueue_links/enqueue_links.d.ts +61 -154
  47. package/enqueue_links/enqueue_links.js +40 -232
  48. package/enqueue_links/index.d.ts +0 -1
  49. package/enqueue_links/index.js +0 -1
  50. package/enqueue_links/shared.d.ts +55 -28
  51. package/enqueue_links/shared.js +116 -69
  52. package/errors.d.ts +82 -4
  53. package/errors.js +101 -5
  54. package/events/event_manager.d.ts +35 -9
  55. package/events/event_manager.js +12 -13
  56. package/events/index.d.ts +0 -1
  57. package/events/index.js +0 -1
  58. package/events/local_event_manager.d.ts +15 -3
  59. package/events/local_event_manager.js +39 -13
  60. package/http.d.ts +9 -0
  61. package/http.js +28 -0
  62. package/index.d.ts +7 -5
  63. package/index.js +8 -4
  64. package/iterables.d.ts +79 -0
  65. package/iterables.js +134 -0
  66. package/log.d.ts +77 -1
  67. package/log.js +134 -1
  68. package/memory-storage/consts.d.ts +4 -0
  69. package/memory-storage/consts.js +4 -0
  70. package/memory-storage/index.d.ts +1 -0
  71. package/memory-storage/index.js +1 -0
  72. package/memory-storage/memory-storage.d.ts +44 -0
  73. package/memory-storage/memory-storage.js +160 -0
  74. package/memory-storage/resource-clients/common/base-client.d.ts +4 -0
  75. package/memory-storage/resource-clients/common/base-client.js +6 -0
  76. package/memory-storage/resource-clients/dataset.d.ts +40 -0
  77. package/memory-storage/resource-clients/dataset.js +106 -0
  78. package/memory-storage/resource-clients/key-value-store.d.ts +63 -0
  79. package/memory-storage/resource-clients/key-value-store.js +199 -0
  80. package/memory-storage/resource-clients/request-queue.d.ts +77 -0
  81. package/memory-storage/resource-clients/request-queue.js +414 -0
  82. package/memory-storage/utils.d.ts +16 -0
  83. package/memory-storage/utils.js +41 -0
  84. package/owned_or_injected.d.ts +58 -0
  85. package/owned_or_injected.js +98 -0
  86. package/package.json +17 -16
  87. package/proxy_configuration.d.ts +22 -128
  88. package/proxy_configuration.js +32 -144
  89. package/recoverable_state.d.ts +84 -51
  90. package/recoverable_state.js +175 -72
  91. package/request.d.ts +57 -16
  92. package/request.js +130 -69
  93. package/router.d.ts +193 -21
  94. package/router.js +188 -43
  95. package/serialization.d.ts +0 -1
  96. package/serialization.js +15 -15
  97. package/service_locator.d.ts +165 -0
  98. package/service_locator.js +253 -0
  99. package/session_pool/consts.d.ts +1 -2
  100. package/session_pool/consts.js +1 -2
  101. package/session_pool/errors.d.ts +0 -1
  102. package/session_pool/errors.js +0 -1
  103. package/session_pool/fingerprint.d.ts +9 -0
  104. package/session_pool/fingerprint.js +30 -0
  105. package/session_pool/index.d.ts +0 -2
  106. package/session_pool/index.js +0 -2
  107. package/session_pool/session.d.ts +40 -90
  108. package/session_pool/session.js +103 -160
  109. package/session_pool/session_pool.d.ts +85 -101
  110. package/session_pool/session_pool.js +254 -210
  111. package/storages/batched_adds.d.ts +37 -0
  112. package/storages/batched_adds.js +73 -0
  113. package/storages/dataset.d.ts +114 -56
  114. package/storages/dataset.js +292 -149
  115. package/storages/index.d.ts +9 -9
  116. package/storages/index.js +7 -9
  117. package/storages/key_value_store.d.ts +186 -48
  118. package/storages/key_value_store.js +452 -170
  119. package/storages/key_value_store_codec.d.ts +32 -0
  120. package/storages/key_value_store_codec.js +113 -0
  121. package/storages/request_dedup_cache.d.ts +22 -0
  122. package/storages/request_dedup_cache.js +48 -0
  123. package/storages/request_list.d.ts +54 -129
  124. package/storages/request_list.js +272 -245
  125. package/storages/request_loader.d.ts +133 -0
  126. package/storages/request_loader.js +36 -0
  127. package/storages/request_manager.d.ts +109 -0
  128. package/storages/request_manager.js +1 -0
  129. package/storages/request_manager_tandem.d.ts +55 -23
  130. package/storages/request_manager_tandem.js +139 -57
  131. package/storages/request_queue.d.ts +286 -48
  132. package/storages/request_queue.js +757 -218
  133. package/storages/{sitemap_request_list.d.ts → sitemap_request_loader.d.ts} +43 -91
  134. package/storages/sitemap_request_loader.js +439 -0
  135. package/storages/storage_instance_manager.d.ts +87 -0
  136. package/storages/storage_instance_manager.js +256 -0
  137. package/storages/storage_stats.d.ts +48 -0
  138. package/storages/storage_stats.js +29 -0
  139. package/storages/throttling_request_manager.d.ts +223 -0
  140. package/storages/throttling_request_manager.js +835 -0
  141. package/storages/transaction.d.ts +260 -0
  142. package/storages/transaction.js +287 -0
  143. package/storages/utils.d.ts +59 -11
  144. package/storages/utils.js +75 -15
  145. package/system-info/cpu-info.d.ts +67 -0
  146. package/system-info/cpu-info.js +216 -0
  147. package/system-info/memory-info.d.ts +31 -0
  148. package/system-info/memory-info.js +115 -0
  149. package/system-info/ps-tree.d.ts +17 -0
  150. package/system-info/ps-tree.js +144 -0
  151. package/system-info/runtime.d.ts +14 -0
  152. package/system-info/runtime.js +80 -0
  153. package/typedefs.d.ts +0 -6
  154. package/typedefs.js +0 -1
  155. package/url.d.ts +9 -0
  156. package/url.js +11 -0
  157. package/validators.d.ts +23 -18
  158. package/validators.js +14 -18
  159. package/autoscaling/autoscaled_pool.d.ts.map +0 -1
  160. package/autoscaling/autoscaled_pool.js.map +0 -1
  161. package/autoscaling/index.d.ts.map +0 -1
  162. package/autoscaling/index.js.map +0 -1
  163. package/autoscaling/snapshotter.d.ts.map +0 -1
  164. package/autoscaling/snapshotter.js.map +0 -1
  165. package/autoscaling/system_status.d.ts.map +0 -1
  166. package/autoscaling/system_status.js.map +0 -1
  167. package/configuration.d.ts.map +0 -1
  168. package/configuration.js.map +0 -1
  169. package/cookie_utils.d.ts.map +0 -1
  170. package/cookie_utils.js.map +0 -1
  171. package/crawlers/context_pipeline.d.ts.map +0 -1
  172. package/crawlers/context_pipeline.js.map +0 -1
  173. package/crawlers/crawler_commons.d.ts.map +0 -1
  174. package/crawlers/crawler_commons.js.map +0 -1
  175. package/crawlers/crawler_utils.d.ts +0 -10
  176. package/crawlers/crawler_utils.d.ts.map +0 -1
  177. package/crawlers/crawler_utils.js +0 -12
  178. package/crawlers/crawler_utils.js.map +0 -1
  179. package/crawlers/error_snapshotter.d.ts.map +0 -1
  180. package/crawlers/error_snapshotter.js.map +0 -1
  181. package/crawlers/error_tracker.d.ts.map +0 -1
  182. package/crawlers/error_tracker.js.map +0 -1
  183. package/crawlers/index.d.ts.map +0 -1
  184. package/crawlers/index.js.map +0 -1
  185. package/crawlers/internals/types.d.ts.map +0 -1
  186. package/crawlers/internals/types.js.map +0 -1
  187. package/crawlers/statistics.d.ts.map +0 -1
  188. package/crawlers/statistics.js.map +0 -1
  189. package/enqueue_links/enqueue_links.d.ts.map +0 -1
  190. package/enqueue_links/enqueue_links.js.map +0 -1
  191. package/enqueue_links/index.d.ts.map +0 -1
  192. package/enqueue_links/index.js.map +0 -1
  193. package/enqueue_links/shared.d.ts.map +0 -1
  194. package/enqueue_links/shared.js.map +0 -1
  195. package/errors.d.ts.map +0 -1
  196. package/errors.js.map +0 -1
  197. package/events/event_manager.d.ts.map +0 -1
  198. package/events/event_manager.js.map +0 -1
  199. package/events/index.d.ts.map +0 -1
  200. package/events/index.js.map +0 -1
  201. package/events/local_event_manager.d.ts.map +0 -1
  202. package/events/local_event_manager.js.map +0 -1
  203. package/http_clients/base-http-client.d.ts +0 -140
  204. package/http_clients/base-http-client.d.ts.map +0 -1
  205. package/http_clients/base-http-client.js +0 -40
  206. package/http_clients/base-http-client.js.map +0 -1
  207. package/http_clients/form-data-like.d.ts +0 -67
  208. package/http_clients/form-data-like.d.ts.map +0 -1
  209. package/http_clients/form-data-like.js +0 -5
  210. package/http_clients/form-data-like.js.map +0 -1
  211. package/http_clients/got-scraping-http-client.d.ts +0 -20
  212. package/http_clients/got-scraping-http-client.d.ts.map +0 -1
  213. package/http_clients/got-scraping-http-client.js +0 -85
  214. package/http_clients/got-scraping-http-client.js.map +0 -1
  215. package/http_clients/index.d.ts +0 -3
  216. package/http_clients/index.d.ts.map +0 -1
  217. package/http_clients/index.js +0 -3
  218. package/http_clients/index.js.map +0 -1
  219. package/index.d.ts.map +0 -1
  220. package/index.js.map +0 -1
  221. package/log.d.ts.map +0 -1
  222. package/log.js.map +0 -1
  223. package/proxy_configuration.d.ts.map +0 -1
  224. package/proxy_configuration.js.map +0 -1
  225. package/recoverable_state.d.ts.map +0 -1
  226. package/recoverable_state.js.map +0 -1
  227. package/request.d.ts.map +0 -1
  228. package/request.js.map +0 -1
  229. package/router.d.ts.map +0 -1
  230. package/router.js.map +0 -1
  231. package/serialization.d.ts.map +0 -1
  232. package/serialization.js.map +0 -1
  233. package/session_pool/consts.d.ts.map +0 -1
  234. package/session_pool/consts.js.map +0 -1
  235. package/session_pool/errors.d.ts.map +0 -1
  236. package/session_pool/errors.js.map +0 -1
  237. package/session_pool/events.d.ts +0 -3
  238. package/session_pool/events.d.ts.map +0 -1
  239. package/session_pool/events.js +0 -3
  240. package/session_pool/events.js.map +0 -1
  241. package/session_pool/index.d.ts.map +0 -1
  242. package/session_pool/index.js.map +0 -1
  243. package/session_pool/session.d.ts.map +0 -1
  244. package/session_pool/session.js.map +0 -1
  245. package/session_pool/session_pool.d.ts.map +0 -1
  246. package/session_pool/session_pool.js.map +0 -1
  247. package/storages/access_checking.d.ts +0 -13
  248. package/storages/access_checking.d.ts.map +0 -1
  249. package/storages/access_checking.js +0 -14
  250. package/storages/access_checking.js.map +0 -1
  251. package/storages/dataset.d.ts.map +0 -1
  252. package/storages/dataset.js.map +0 -1
  253. package/storages/index.d.ts.map +0 -1
  254. package/storages/index.js.map +0 -1
  255. package/storages/key_value_store.d.ts.map +0 -1
  256. package/storages/key_value_store.js.map +0 -1
  257. package/storages/request_list.d.ts.map +0 -1
  258. package/storages/request_list.js.map +0 -1
  259. package/storages/request_list_adapter.d.ts +0 -58
  260. package/storages/request_list_adapter.d.ts.map +0 -1
  261. package/storages/request_list_adapter.js +0 -81
  262. package/storages/request_list_adapter.js.map +0 -1
  263. package/storages/request_manager_tandem.d.ts.map +0 -1
  264. package/storages/request_manager_tandem.js.map +0 -1
  265. package/storages/request_provider.d.ts +0 -371
  266. package/storages/request_provider.d.ts.map +0 -1
  267. package/storages/request_provider.js +0 -585
  268. package/storages/request_provider.js.map +0 -1
  269. package/storages/request_queue.d.ts.map +0 -1
  270. package/storages/request_queue.js.map +0 -1
  271. package/storages/request_queue_v2.d.ts +0 -87
  272. package/storages/request_queue_v2.d.ts.map +0 -1
  273. package/storages/request_queue_v2.js +0 -438
  274. package/storages/request_queue_v2.js.map +0 -1
  275. package/storages/sitemap_request_list.d.ts.map +0 -1
  276. package/storages/sitemap_request_list.js +0 -430
  277. package/storages/sitemap_request_list.js.map +0 -1
  278. package/storages/storage_manager.d.ts +0 -58
  279. package/storages/storage_manager.d.ts.map +0 -1
  280. package/storages/storage_manager.js +0 -105
  281. package/storages/storage_manager.js.map +0 -1
  282. package/storages/utils.d.ts.map +0 -1
  283. package/storages/utils.js.map +0 -1
  284. package/typedefs.d.ts.map +0 -1
  285. package/typedefs.js.map +0 -1
  286. package/validators.d.ts.map +0 -1
  287. package/validators.js.map +0 -1
@@ -1,22 +1,32 @@
1
- import ow from 'ow';
2
- import { Configuration } from '../configuration.js';
3
- import { log as defaultLog } from '../log.js';
1
+ import { z } from 'zod';
2
+ import { convertStateSync, RecoverableState } from '../recoverable_state.js';
3
+ import { serviceLocator } from '../service_locator.js';
4
4
  import { KeyValueStore } from '../storages/key_value_store.js';
5
+ import { parseArgument, schemas, validators } from '../validators.js';
5
6
  import { ErrorTracker } from './error_tracker.js';
6
7
  /**
7
8
  * @ignore
8
9
  */
9
- class Job {
10
- lastRunAt = null;
11
- durationMillis;
10
+ class RequestProcessingRecord {
11
+ #lastRunAt = null;
12
12
  run() {
13
- this.lastRunAt = Date.now();
13
+ this.#lastRunAt = Date.now();
14
14
  }
15
15
  finish() {
16
- this.durationMillis = Date.now() - this.lastRunAt;
17
- return this.durationMillis;
16
+ return Date.now() - this.#lastRunAt;
18
17
  }
19
18
  }
19
+ const statisticsOptionsSchema = z.strictObject({
20
+ logIntervalSecs: schemas.anyNumber.default(60),
21
+ logMessage: z.string().default('Statistics'),
22
+ log: validators.logger.optional(),
23
+ keyValueStore: z.instanceof(KeyValueStore).optional(),
24
+ // `schemas.anyObject` passes values through by reference (object schemas return a pruned plain copy).
25
+ persistenceOptions: schemas.anyObject.default(() => ({ enable: true })),
26
+ saveErrorSnapshots: z.boolean().default(false),
27
+ id: z.union([schemas.anyNumber, z.string()]).optional(),
28
+ stateExtension: schemas.anyObject.default(() => ({})),
29
+ });
20
30
  const errorTrackerConfig = {
21
31
  showErrorCode: true,
22
32
  showErrorName: true,
@@ -25,18 +35,130 @@ const errorTrackerConfig = {
25
35
  showErrorMessage: true,
26
36
  showFullMessage: false,
27
37
  };
38
+ /**
39
+ * The persisted record, in the order it is written - the schema rebuilds the object on the way out, so the field
40
+ * order here *is* the record's field order (guarded by a test).
41
+ *
42
+ * JSON has no infinity, so the three fields that are `Infinity` until the first request settles are written as
43
+ * `null`. Both {@link Statistics.serializeState} and {@link Statistics.deserializeState} run through this,
44
+ * which is what keeps them describing the same record.
45
+ *
46
+ * Nothing is optional on purpose: the record has always carried every field, so one missing a field is not one of
47
+ * ours and is discarded whole rather than partially trusted - a counter restored as a string would poison every
48
+ * later increment.
49
+ *
50
+ * Custom fields are not this schema's business either way: they are added to the record after the encode and
51
+ * validated by their own conversion on the way back, so the keys it does not know about are simply dropped here.
52
+ */
53
+ const persistedStatisticState = z.object({
54
+ requestsSucceeded: z.number(),
55
+ requestsFailed: z.number(),
56
+ requestsRetries: z.number(),
57
+ requestsFailedPerMinute: z.number().nullable(),
58
+ requestsSucceededPerMinute: z.number().nullable(),
59
+ requestMinDurationMillis: z.number().nullable(),
60
+ requestMaxDurationMillis: z.number(),
61
+ requestTotalFailedDurationMillis: z.number(),
62
+ requestTotalSucceededDurationMillis: z.number(),
63
+ crawlerStartedAt: z.string().nullable(),
64
+ crawlerFinishedAt: z.string().nullable(),
65
+ statsPersistedAt: z.string(),
66
+ crawlerRuntimeMillis: z.number(),
67
+ crawlerLastStartTimestamp: z.number(),
68
+ // A retry count that never occurred leaves a hole in the live histogram, written out as a `null`. We
69
+ // once saw a record whose histogram was not an array at all and crashed the crawler on load.
70
+ requestRetryHistogram: z.array(z.number().nullable()),
71
+ statsId: z.string(),
72
+ requestAvgFailedDurationMillis: z.number().nullable(),
73
+ requestAvgSucceededDurationMillis: z.number().nullable(),
74
+ requestTotalDurationMillis: z.number(),
75
+ requestsTotal: z.number(),
76
+ requestsWithStatusCode: z.record(z.string(), z.number()),
77
+ errors: z.record(z.string(), z.unknown()),
78
+ retryErrors: z.record(z.string(), z.unknown()),
79
+ });
80
+ /** `Infinity` is what the statistics use for "nothing to average yet"; JSON has only `null` for it. */
81
+ function finiteOrNull(value) {
82
+ return Number.isFinite(value) ? value : null;
83
+ }
84
+ /**
85
+ * The conversion between the live state and the record above, in both directions.
86
+ *
87
+ * Built per instance rather than kept as a constant because a record carries three things the state does not: the
88
+ * instance `id`, the derived aggregates of the overridable {@link Statistics.calculate}, and - on the way back -
89
+ * the fields that are rebuilt from the instance's default state rather than restored, the error trackers among them.
90
+ *
91
+ * The model side is deliberately opaque: zod rebuilds what it validates, and `state.errors` has to stay the very
92
+ * object the error trackers write into, not a copy of it.
93
+ */
94
+ function buildStatisticStateCodec(statistics) {
95
+ return z.codec(persistedStatisticState, z.custom(), {
96
+ decode: (record) => ({
97
+ ...statistics.defaultState(),
98
+ requestsSucceeded: record.requestsSucceeded,
99
+ requestsFailed: record.requestsFailed,
100
+ requestsRetries: record.requestsRetries,
101
+ requestTotalFailedDurationMillis: record.requestTotalFailedDurationMillis,
102
+ requestTotalSucceededDurationMillis: record.requestTotalSucceededDurationMillis,
103
+ // Restoring the `null` as-is would make every later `duration < min` comparison fail, leaving the
104
+ // minimum `null` for the rest of the run.
105
+ requestMinDurationMillis: record.requestMinDurationMillis ?? Infinity,
106
+ requestMaxDurationMillis: record.requestMaxDurationMillis,
107
+ crawlerRuntimeMillis: record.crawlerRuntimeMillis,
108
+ // A `null` stands for the zero requests that reached that retry count - restore it as such.
109
+ requestRetryHistogram: record.requestRetryHistogram.map((count) => count ?? 0),
110
+ // The record keeps ISO strings, the live state keeps `Date`s.
111
+ crawlerStartedAt: record.crawlerStartedAt === null ? null : new Date(record.crawlerStartedAt),
112
+ crawlerFinishedAt: record.crawlerFinishedAt === null ? null : new Date(record.crawlerFinishedAt),
113
+ statsPersistedAt: new Date(record.statsPersistedAt),
114
+ // Rebased so that the runtime reported by `calculate()` spans the migration instead of restarting.
115
+ instanceStart: Date.now() - (new Date(record.statsPersistedAt).getTime() - record.crawlerLastStartTimestamp),
116
+ }),
117
+ encode: (state) => {
118
+ // The per-minute rates fall through into `counters` and are overwritten below by `calculate()`'s,
119
+ // which are the ones the record carries.
120
+ const { requestsWithStatusCode, errors, retryErrors, requestRetryHistogram, instanceStart, ...counters } = state;
121
+ // Every rate and average `calculate()` derives is `Infinity` until the run is long enough, or until
122
+ // something has succeeded or failed, to divide by.
123
+ const { requestAvgFailedDurationMillis, requestAvgSucceededDurationMillis, requestsFailedPerMinute, requestsSucceededPerMinute, ...aggregates } = statistics.calculate();
124
+ return {
125
+ ...counters,
126
+ requestMinDurationMillis: finiteOrNull(state.requestMinDurationMillis),
127
+ crawlerStartedAt: state.crawlerStartedAt ? new Date(state.crawlerStartedAt).toISOString() : null,
128
+ crawlerFinishedAt: state.crawlerFinishedAt ? new Date(state.crawlerFinishedAt).toISOString() : null,
129
+ statsPersistedAt: new Date().toISOString(),
130
+ crawlerLastStartTimestamp: instanceStart,
131
+ // `Array.from`, not `map` - a hole left by a retry count no request reached is skipped by `map`
132
+ // and would stay a hole, which is not something the record can carry.
133
+ requestRetryHistogram: Array.from(requestRetryHistogram, (count) => count ?? null),
134
+ statsId: statistics.statsId,
135
+ ...aggregates,
136
+ requestAvgFailedDurationMillis: finiteOrNull(requestAvgFailedDurationMillis),
137
+ requestAvgSucceededDurationMillis: finiteOrNull(requestAvgSucceededDurationMillis),
138
+ requestsFailedPerMinute: finiteOrNull(requestsFailedPerMinute),
139
+ requestsSucceededPerMinute: finiteOrNull(requestsSucceededPerMinute),
140
+ requestsWithStatusCode,
141
+ errors,
142
+ retryErrors,
143
+ };
144
+ },
145
+ });
146
+ }
28
147
  /**
29
148
  * The statistics class provides an interface to collecting and logging run
30
149
  * statistics for requests.
31
150
  *
32
151
  * All statistic information is saved on key value store
33
- * under the key `SDK_CRAWLER_STATISTICS_*`, persists between
152
+ * under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
34
153
  * migrations and abort/resurrect
35
154
  *
155
+ * Custom fields are tracked by passing {@link StatisticsOptions.stateExtension|`stateExtension`} - the extra fields are then part
156
+ * of {@link Statistics.state|`state`}, persisted and restored along with the built-in ones.
157
+ *
36
158
  * @category Crawlers
37
159
  */
38
160
  export class Statistics {
39
- static id = 0;
161
+ static #id = 0;
40
162
  /**
41
163
  * An error tracker for final retry errors.
42
164
  */
@@ -48,99 +170,148 @@ export class Statistics {
48
170
  /**
49
171
  * Statistic instance id.
50
172
  */
51
- id = Statistics.id++; // assign an id while incrementing so it can be saved/restored from KV
173
+ id;
174
+ #persistStateKey;
175
+ #stateCodec;
176
+ #recoverableState;
177
+ #stateExtension;
178
+ #defaultStateExtension;
179
+ #stateExtensionKeys;
180
+ #logIntervalMillis;
181
+ #logMessage;
182
+ #requestsInProgress = new Map();
183
+ log;
184
+ #logInterval;
52
185
  /**
53
186
  * Current statistic state used for doing calculations on {@link Statistics.calculate} calls
54
187
  */
55
- state;
188
+ get state() {
189
+ return this.#recoverableState.currentValue;
190
+ }
56
191
  /**
57
192
  * Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
58
193
  */
59
- requestRetryHistogram = [];
60
- /**
61
- * Contains the associated Configuration instance
62
- */
63
- config;
64
- keyValueStore = undefined;
65
- persistStateKey = `SDK_CRAWLER_STATISTICS_${this.id}`;
66
- logIntervalMillis;
67
- logMessage;
68
- listener;
69
- requestsInProgress = new Map();
70
- log;
71
- instanceStart;
72
- logInterval;
73
- events;
74
- persistenceOptions;
194
+ get requestRetryHistogram() {
195
+ return this.state.requestRetryHistogram;
196
+ }
75
197
  /**
76
- * @internal
198
+ * Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
199
+ * persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
77
200
  */
78
201
  constructor(options = {}) {
79
- ow(options, ow.object.exactShape({
80
- logIntervalSecs: ow.optional.number,
81
- logMessage: ow.optional.string,
82
- log: ow.optional.object,
83
- keyValueStore: ow.optional.object,
84
- config: ow.optional.object,
85
- persistenceOptions: ow.optional.object,
86
- saveErrorSnapshots: ow.optional.boolean,
87
- }));
88
- const { logIntervalSecs = 60, logMessage = 'Statistics', keyValueStore, config = Configuration.getGlobalConfig(), persistenceOptions = {
89
- enable: true,
90
- }, saveErrorSnapshots = false, } = options;
91
- this.log = (options.log ?? defaultLog).child({ prefix: 'Statistics' });
202
+ const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id, stateExtension, } = parseArgument(options, statisticsOptionsSchema);
203
+ this.id = id ?? String(Statistics.#id++);
204
+ this.#persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
205
+ this.log = (log ?? serviceLocator.getLogger()).child({ prefix: 'Statistics' });
92
206
  this.errorTracker = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
93
207
  this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
94
- this.logIntervalMillis = logIntervalSecs * 1000;
95
- this.logMessage = logMessage;
96
- this.keyValueStore = keyValueStore;
97
- this.listener = this.persistState.bind(this);
98
- this.events = config.getEventManager();
99
- this.config = config;
100
- this.persistenceOptions = persistenceOptions;
208
+ this.#logIntervalMillis = logIntervalSecs * 1000;
209
+ this.#logMessage = logMessage;
210
+ this.#stateExtension = stateExtension;
211
+ this.#defaultStateExtension = this.#resolveDefaultStateExtension(this.#stateExtension);
212
+ this.#stateExtensionKeys = Object.keys(this.#defaultStateExtension());
213
+ for (const key of this.#stateExtensionKeys) {
214
+ if (key in this.#builtInDefaultState()) {
215
+ throw new Error(`The custom statistics field \`${String(key)}\` collides with a built-in one - it would shadow ` +
216
+ 'the value the crawler tracks. Rename it in `stateExtension`.');
217
+ }
218
+ }
219
+ // `calculate()` is late-bound on purpose - it is an override point, and a subclass's must be the one that runs.
220
+ this.#stateCodec = buildStatisticStateCodec({
221
+ statsId: this.id,
222
+ defaultState: () => this.#defaultState(),
223
+ calculate: () => this.calculate(),
224
+ });
225
+ this.#recoverableState = new RecoverableState({
226
+ persistStateKey: this.#persistStateKey,
227
+ persistenceEnabled: persistenceOptions.enable,
228
+ keyValueStore,
229
+ logger: this.log,
230
+ defaultState: () => this.#defaultState(),
231
+ serialize: (state) => this.#serializeState(state),
232
+ deserialize: (persistedState) => this.#deserializeState(persistedState),
233
+ });
101
234
  // initialize by "resetting"
102
235
  this.reset();
103
236
  }
104
237
  /**
105
- * Set the current statistic instance to pristine values
238
+ * Set the current statistic instance to pristine values.
239
+ *
240
+ * The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
106
241
  */
107
242
  reset() {
108
243
  this.errorTracker.reset();
109
244
  this.errorTrackerRetry.reset();
110
- this.state = {
111
- requestsFinished: 0,
245
+ this.#recoverableState.reset();
246
+ this.#requestsInProgress.clear();
247
+ }
248
+ /** The pristine state a new instance starts with and {@link Statistics.reset} restores. */
249
+ #defaultState() {
250
+ return {
251
+ ...this.#builtInDefaultState(),
252
+ ...this.#defaultStateExtension(),
253
+ };
254
+ }
255
+ /**
256
+ * The factory behind the custom half of the default state - it has to hand out a fresh object every time, or a
257
+ * `reset()` would write through to the defaults of the next one.
258
+ *
259
+ * With no `defaultState` given, the defaults are what `deserialize` makes of an empty record. That keeps a
260
+ * single declaration of the custom fields - a schema with a `.default()` per field is enough - and the defaults
261
+ * cannot then disagree with the conversion that has to accept them back.
262
+ */
263
+ #resolveDefaultStateExtension(options) {
264
+ const { defaultState, deserialize } = options;
265
+ if (typeof defaultState === 'function') {
266
+ return defaultState;
267
+ }
268
+ if (defaultState !== undefined) {
269
+ return () => structuredClone(defaultState);
270
+ }
271
+ if (deserialize === undefined) {
272
+ return () => ({});
273
+ }
274
+ return () => {
275
+ try {
276
+ return convertStateSync(deserialize, {}, this.#persistStateKey);
277
+ }
278
+ catch (error) {
279
+ throw new Error('Could not derive the default values of the custom statistics fields from `stateExtension.deserialize` - ' +
280
+ 'give every field a default, or declare `stateExtension.defaultState` explicitly.', { cause: error });
281
+ }
282
+ };
283
+ }
284
+ /** The built-in half of {@link Statistics.defaultState}, before any custom fields are merged over it. */
285
+ #builtInDefaultState() {
286
+ return {
287
+ requestsSucceeded: 0,
112
288
  requestsFailed: 0,
113
289
  requestsRetries: 0,
114
290
  requestsFailedPerMinute: 0,
115
- requestsFinishedPerMinute: 0,
291
+ requestsSucceededPerMinute: 0,
116
292
  requestMinDurationMillis: Infinity,
117
293
  requestMaxDurationMillis: 0,
118
294
  requestTotalFailedDurationMillis: 0,
119
- requestTotalFinishedDurationMillis: 0,
295
+ requestTotalSucceededDurationMillis: 0,
120
296
  crawlerStartedAt: null,
121
297
  crawlerFinishedAt: null,
122
298
  statsPersistedAt: null,
123
299
  crawlerRuntimeMillis: 0,
124
300
  requestsWithStatusCode: {},
301
+ // Aliases, not copies - the trackers keep writing into these objects.
125
302
  errors: this.errorTracker.result,
126
303
  retryErrors: this.errorTrackerRetry.result,
304
+ requestRetryHistogram: [],
305
+ instanceStart: Date.now(),
127
306
  };
128
- this.requestRetryHistogram.length = 0;
129
- this.requestsInProgress.clear();
130
- this.instanceStart = Date.now();
131
- this._teardown();
132
307
  }
133
308
  /**
134
- * @param options - Override the persistence options provided in the constructor
309
+ * Clear the persisted statistics record, leaving the in-memory state alone.
310
+ *
311
+ * Throws while capturing - the next PERSIST_STATE event would write the record straight back.
135
312
  */
136
- async resetStore(options) {
137
- if (!this.persistenceOptions.enable && !options?.enable) {
138
- return;
139
- }
140
- if (!this.keyValueStore) {
141
- return;
142
- }
143
- await this.keyValueStore.setValue(this.persistStateKey, null);
313
+ async resetStore() {
314
+ await this.#recoverableState.resetStore();
144
315
  }
145
316
  /**
146
317
  * Increments the status code counter.
@@ -152,62 +323,62 @@ export class Statistics {
152
323
  }
153
324
  this.state.requestsWithStatusCode[s]++;
154
325
  }
155
- /**
156
- * Starts a job
157
- * @ignore
158
- */
159
- startJob(id) {
160
- let job = this.requestsInProgress.get(id);
161
- if (!job)
162
- job = new Job();
163
- job.run();
164
- this.requestsInProgress.set(id, job);
326
+ /** @ignore */
327
+ recordRequestStart(id) {
328
+ let record = this.#requestsInProgress.get(id);
329
+ if (!record)
330
+ record = new RequestProcessingRecord();
331
+ record.run();
332
+ this.#requestsInProgress.set(id, record);
165
333
  }
166
- /**
167
- * Mark job as finished and sets the state
168
- * @ignore
169
- */
170
- finishJob(id, retryCount) {
171
- const job = this.requestsInProgress.get(id);
172
- if (!job)
334
+ /** @ignore */
335
+ recordRequestSuccess(id, retryCount) {
336
+ const record = this.#requestsInProgress.get(id);
337
+ if (!record)
173
338
  return;
174
- const jobDurationMillis = job.finish();
175
- this.state.requestsFinished++;
176
- this.state.requestTotalFinishedDurationMillis += jobDurationMillis;
177
- this._saveRetryCountForJob(retryCount);
178
- if (jobDurationMillis < this.state.requestMinDurationMillis)
179
- this.state.requestMinDurationMillis = jobDurationMillis;
180
- if (jobDurationMillis > this.state.requestMaxDurationMillis)
181
- this.state.requestMaxDurationMillis = jobDurationMillis;
182
- this.requestsInProgress.delete(id);
339
+ const durationMillis = record.finish();
340
+ this.state.requestsSucceeded++;
341
+ this.state.requestTotalSucceededDurationMillis += durationMillis;
342
+ this.saveRetryCountForRequest(retryCount);
343
+ if (durationMillis < this.state.requestMinDurationMillis)
344
+ this.state.requestMinDurationMillis = durationMillis;
345
+ if (durationMillis > this.state.requestMaxDurationMillis)
346
+ this.state.requestMaxDurationMillis = durationMillis;
347
+ this.#requestsInProgress.delete(id);
348
+ }
349
+ /** @ignore */
350
+ recordRequestFailure(id, retryCount) {
351
+ const record = this.#requestsInProgress.get(id);
352
+ if (!record)
353
+ return;
354
+ this.state.requestTotalFailedDurationMillis += record.finish();
355
+ this.state.requestsFailed++;
356
+ this.saveRetryCountForRequest(retryCount);
357
+ this.#requestsInProgress.delete(id);
183
358
  }
184
359
  /**
185
- * Mark job as failed and sets the state
360
+ * Discards a started request without affecting the succeeded/failed counters, e.g. when a request
361
+ * turns out to be skipped (robots.txt, enqueue strategy) after `recordRequestStart` was already
362
+ * called for it.
186
363
  * @ignore
187
364
  */
188
- failJob(id, retryCount) {
189
- const job = this.requestsInProgress.get(id);
190
- if (!job)
191
- return;
192
- this.state.requestTotalFailedDurationMillis += job.finish();
193
- this.state.requestsFailed++;
194
- this._saveRetryCountForJob(retryCount);
195
- this.requestsInProgress.delete(id);
365
+ discardRequestRecord(id) {
366
+ this.#requestsInProgress.delete(id);
196
367
  }
197
368
  /**
198
369
  * Calculate the current statistics
199
370
  */
200
371
  calculate() {
201
- const { requestsFailed, requestsFinished, requestTotalFailedDurationMillis, requestTotalFinishedDurationMillis, } = this.state;
202
- const totalMillis = Date.now() - this.instanceStart;
372
+ const { requestsFailed, requestsSucceeded, requestTotalFailedDurationMillis, requestTotalSucceededDurationMillis, } = this.state;
373
+ const totalMillis = Date.now() - this.state.instanceStart;
203
374
  const totalMinutes = totalMillis / 1000 / 60;
204
375
  return {
205
376
  requestAvgFailedDurationMillis: Math.round(requestTotalFailedDurationMillis / requestsFailed) || Infinity,
206
- requestAvgFinishedDurationMillis: Math.round(requestTotalFinishedDurationMillis / requestsFinished) || Infinity,
207
- requestsFinishedPerMinute: Math.round(requestsFinished / totalMinutes) || 0,
377
+ requestAvgSucceededDurationMillis: Math.round(requestTotalSucceededDurationMillis / requestsSucceeded) || Infinity,
378
+ requestsSucceededPerMinute: Math.round(requestsSucceeded / totalMinutes) || 0,
208
379
  requestsFailedPerMinute: Math.floor(requestsFailed / totalMinutes) || 0,
209
- requestTotalDurationMillis: requestTotalFinishedDurationMillis + requestTotalFailedDurationMillis,
210
- requestsTotal: requestsFailed + requestsFinished,
380
+ requestTotalDurationMillis: requestTotalSucceededDurationMillis + requestTotalFailedDurationMillis,
381
+ requestsTotal: requestsFailed + requestsSucceeded,
211
382
  crawlerRuntimeMillis: totalMillis,
212
383
  };
213
384
  }
@@ -216,131 +387,141 @@ export class Statistics {
216
387
  * displaying the current state in predefined intervals
217
388
  */
218
389
  async startCapturing() {
219
- this.keyValueStore ??= await KeyValueStore.open(null, { config: this.config });
390
+ // A single instance drives one logging interval and one PERSIST_STATE listener, so a second concurrent
391
+ // capture (e.g. sharing one instance across crawlers running at once) would orphan the first. Fail loudly.
392
+ if (this.#logInterval) {
393
+ throw new Error('Statistics.startCapturing() was already called - this instance is already capturing.');
394
+ }
395
+ await this.#recoverableState.initialize();
396
+ // After the load, so that a restored record keeps the timestamp of the run it belongs to.
220
397
  if (this.state.crawlerStartedAt === null) {
221
398
  this.state.crawlerStartedAt = new Date();
222
399
  }
223
- if (this.persistenceOptions.enable) {
224
- await this._maybeLoadStatistics();
225
- this.events.on("persistState" /* EventType.PERSIST_STATE */, this.listener);
226
- }
227
- this.logInterval = setInterval(() => {
228
- this.log.info(this.logMessage, {
400
+ this.#logInterval = setInterval(() => {
401
+ this.log.info(this.#logMessage, {
229
402
  ...this.calculate(),
230
403
  retryHistogram: this.requestRetryHistogram,
231
404
  });
232
- }, this.logIntervalMillis);
405
+ }, this.#logIntervalMillis);
233
406
  }
234
407
  /**
235
408
  * Stops logging and remove event listeners, then persist
236
409
  */
237
410
  async stopCapturing() {
238
- this._teardown();
411
+ this.#stopLogging();
239
412
  this.state.crawlerFinishedAt = new Date();
240
- await this.persistState();
413
+ await this.#recoverableState.teardown();
241
414
  }
242
- _saveRetryCountForJob(retryCount) {
415
+ saveRetryCountForRequest(retryCount) {
243
416
  if (retryCount > 0)
244
417
  this.state.requestsRetries++;
245
418
  this.requestRetryHistogram[retryCount] ??= 0;
246
419
  this.requestRetryHistogram[retryCount]++;
247
420
  }
248
421
  /**
249
- * Persist internal state to the key value store
250
- * @param options - Override the persistence options provided in the constructor
422
+ * Persist internal state to the key value store.
423
+ *
424
+ * Statistics are bookkeeping - a store that refuses the write is worth a warning, not a failed crawl. The
425
+ * crawler calls this from its migration handler, where a rejection would go unhandled.
251
426
  */
252
- async persistState(options) {
253
- if (!this.persistenceOptions.enable && !options?.enable) {
254
- return;
255
- }
256
- // this might be called before startCapturing was called without using await, should not crash
257
- if (!this.keyValueStore) {
258
- return;
427
+ async persistState() {
428
+ await this.#recoverableState
429
+ .persistState()
430
+ .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.#persistStateKey}`, { error }));
431
+ }
432
+ /** Rebuilds the state from a persisted record. */
433
+ #deserializeState(persistedState) {
434
+ // The cast covers the custom fields, whose type is open here - and the record is an unvalidated blob off the
435
+ // key-value store either way, which is what the decode is for. Their keys are not in the schema, so the
436
+ // decode drops them; `#restoreStateExtension` is what brings them back.
437
+ const restored = z.safeDecode(this.#stateCodec, persistedState);
438
+ if (!restored.success) {
439
+ // Statistics are bookkeeping - a record that cannot be made sense of is worth a warning and a fresh
440
+ // start, not a failed crawl.
441
+ this.log.warning('Received invalid state from Key-value store, starting the statistics from scratch.', {
442
+ persistStateKey: this.#persistStateKey,
443
+ issues: restored.error.issues,
444
+ });
445
+ return this.#defaultState();
259
446
  }
260
- this.log.debug('Persisting state', { persistStateKey: this.persistStateKey });
261
- // use half the interval of `persistState` to avoid race conditions
262
- const persistStateIntervalMillis = this.config.get('persistStateIntervalMillis');
263
- const timeoutSecs = persistStateIntervalMillis / 2_000;
264
- await this.keyValueStore
265
- .setValue(this.persistStateKey, this.toJSON(), {
266
- timeoutSecs,
267
- doNotRetryTimeouts: true,
268
- })
269
- .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.persistStateKey}`, { error }));
447
+ this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.#persistStateKey });
448
+ return { ...this.#defaultState(), ...restored.data, ...this.#restoreStateExtension(persistedState) };
270
449
  }
271
450
  /**
272
- * Loads the current statistic from the key value store if any
451
+ * The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they were persisted - the codec only rebuilds
452
+ * the built-in ones.
453
+ *
454
+ * Given a `deserialize`, the whole record goes through it, so a field the record does not carry - one declared
455
+ * after the record was written - comes back as whatever default the conversion gives it. Without one, the
456
+ * declared keys are copied over as they were persisted and a missing one keeps the default it already has.
273
457
  */
274
- async _maybeLoadStatistics() {
275
- // this might be called before startCapturing was called without using await, should not crash
276
- if (!this.keyValueStore) {
277
- return;
458
+ #restoreStateExtension(persistedState) {
459
+ const { deserialize } = this.#stateExtension;
460
+ if (deserialize === undefined) {
461
+ const restored = {};
462
+ for (const key of this.#stateExtensionKeys) {
463
+ // The record is an unvalidated blob and there is no conversion to check it with, so this is the
464
+ // caller's word for it - the state ends up holding whatever was written. The two halves only line
465
+ // up at all because the fields are persisted as they are without a `serialize`.
466
+ const persistedValue = persistedState[key];
467
+ if (persistedValue !== undefined) {
468
+ restored[key] = persistedValue;
469
+ }
470
+ }
471
+ return restored;
278
472
  }
279
- const savedState = await this.keyValueStore.getValue(this.persistStateKey);
280
- if (!savedState)
281
- return;
282
- // We saw a run where the requestRetryHistogram was not iterable and crashed
283
- // the crawler. Adding some logging to monitor this problem in the future.
284
- if (!Array.isArray(savedState.requestRetryHistogram)) {
285
- this.log.warning('Received invalid state from Key-value store.', {
286
- persistStateKey: this.persistStateKey,
287
- state: savedState,
288
- });
473
+ try {
474
+ return convertStateSync(deserialize, persistedState, this.#persistStateKey);
475
+ }
476
+ catch (error) {
477
+ // Same policy as a built-in field that cannot be made sense of, but scoped to the custom ones - a
478
+ // corrupt counter of your own is no reason to throw away the crawler's.
479
+ this.log.warning('Received invalid custom statistics fields from Key-value store, starting those from scratch.', { persistStateKey: this.#persistStateKey, error });
480
+ return {};
289
481
  }
290
- this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.persistStateKey });
291
- // the `requestRetryHistogram` array might be very large, we could end up with
292
- // `RangeError: Maximum call stack size exceeded` if we use `a.push(...b)`
293
- savedState.requestRetryHistogram.forEach((idx) => this.requestRetryHistogram.push(idx));
294
- this.state.requestsFinished = savedState.requestsFinished;
295
- this.state.requestsFailed = savedState.requestsFailed;
296
- this.state.requestsRetries = savedState.requestsRetries;
297
- this.state.requestTotalFailedDurationMillis = savedState.requestTotalFailedDurationMillis;
298
- this.state.requestTotalFinishedDurationMillis = savedState.requestTotalFinishedDurationMillis;
299
- this.state.requestMinDurationMillis = savedState.requestMinDurationMillis;
300
- this.state.requestMaxDurationMillis = savedState.requestMaxDurationMillis;
301
- // persisted state uses ISO date strings
302
- this.state.crawlerFinishedAt = savedState.crawlerFinishedAt ? new Date(savedState.crawlerFinishedAt) : null;
303
- this.state.crawlerStartedAt = savedState.crawlerStartedAt ? new Date(savedState.crawlerStartedAt) : null;
304
- this.state.statsPersistedAt = savedState.statsPersistedAt ? new Date(savedState.statsPersistedAt) : null;
305
- this.state.crawlerRuntimeMillis = savedState.crawlerRuntimeMillis;
306
- this.instanceStart = Date.now() - (+this.state.statsPersistedAt - savedState.crawlerLastStartTimestamp);
307
- this.log.debug('Loaded from KeyValueStore');
308
482
  }
309
- _teardown() {
310
- // this can be called before a call to startCapturing happens (or in a 'finally' block)
311
- this.events.off("persistState" /* EventType.PERSIST_STATE */, this.listener);
312
- if (this.logInterval) {
313
- clearInterval(this.logInterval);
314
- this.logInterval = null;
483
+ #stopLogging() {
484
+ if (this.#logInterval) {
485
+ clearInterval(this.#logInterval);
486
+ this.#logInterval = null;
315
487
  }
316
488
  }
489
+ /**
490
+ * Builds the record written to the key value store, merging in the derived aggregates so that a consumer
491
+ * reading the record does not have to reconstruct them.
492
+ */
493
+ #serializeState(state) {
494
+ const { builtIn, extension } = this.#splitState(state);
495
+ return {
496
+ ...z.encode(this.#stateCodec, builtIn),
497
+ ...this.#serializeStateExtension(extension),
498
+ };
499
+ }
500
+ /** The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they go into the record. */
501
+ #serializeStateExtension(extension) {
502
+ const { serialize } = this.#stateExtension;
503
+ if (serialize === undefined) {
504
+ return extension;
505
+ }
506
+ // Unlike the way back, a failure here throws: the value is the caller's own, and a record written from a
507
+ // state that does not match its own declaration is not worth having.
508
+ return convertStateSync(serialize, extension, this.#persistStateKey);
509
+ }
510
+ /** Separates the declared custom fields from the built-in ones, so that each half goes through its own conversion. */
511
+ #splitState(state) {
512
+ const builtIn = { ...state };
513
+ const extension = {};
514
+ for (const key of this.#stateExtensionKeys) {
515
+ extension[key] = state[key];
516
+ delete builtIn[key];
517
+ }
518
+ return { builtIn, extension };
519
+ }
317
520
  /**
318
521
  * Make this class serializable when called with `JSON.stringify(statsInstance)` directly
319
522
  * or through `keyValueStore.setValue('KEY', statsInstance)`
320
523
  */
321
524
  toJSON() {
322
- // merge all the current state information that can be used from the outside
323
- // without the need to reconstruct for the sake of stats.calculate()
324
- // omit duplicated information
325
- const result = {
326
- ...this.state,
327
- crawlerLastStartTimestamp: this.instanceStart,
328
- crawlerFinishedAt: this.state.crawlerFinishedAt
329
- ? new Date(this.state.crawlerFinishedAt).toISOString()
330
- : null,
331
- crawlerStartedAt: this.state.crawlerStartedAt ? new Date(this.state.crawlerStartedAt).toISOString() : null,
332
- requestRetryHistogram: this.requestRetryHistogram,
333
- statsId: this.id,
334
- statsPersistedAt: new Date().toISOString(),
335
- ...this.calculate(),
336
- };
337
- Reflect.deleteProperty(result, 'requestsWithStatusCode');
338
- Reflect.deleteProperty(result, 'errors');
339
- Reflect.deleteProperty(result, 'retryErrors');
340
- result.requestsWithStatusCode = this.state.requestsWithStatusCode;
341
- result.errors = this.state.errors;
342
- result.retryErrors = this.state.retryErrors;
343
- return result;
525
+ return this.#serializeState(this.state);
344
526
  }
345
527
  }
346
- //# sourceMappingURL=statistics.js.map