envio 3.3.0 → 3.3.1-rc.0

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 (54) hide show
  1. package/package.json +6 -7
  2. package/src/ChainFetching.res +4 -32
  3. package/src/ChainFetching.res.mjs +2 -12
  4. package/src/ChainState.res +73 -53
  5. package/src/ChainState.res.mjs +61 -30
  6. package/src/ChainState.resi +13 -17
  7. package/src/Config.res +5 -0
  8. package/src/Config.res.mjs +1 -0
  9. package/src/CrossChainState.res +2 -8
  10. package/src/CrossChainState.res.mjs +6 -8
  11. package/src/CrossChainState.resi +1 -0
  12. package/src/EffectState.res +183 -44
  13. package/src/EffectState.res.mjs +134 -26
  14. package/src/EffectState.resi +26 -3
  15. package/src/EventProcessing.res +14 -9
  16. package/src/EventProcessing.res.mjs +7 -7
  17. package/src/FetchState.res +18 -29
  18. package/src/FetchState.res.mjs +20 -37
  19. package/src/IndexerState.res +280 -31
  20. package/src/IndexerState.res.mjs +234 -25
  21. package/src/IndexerState.resi +22 -0
  22. package/src/LoadLayer.res +19 -65
  23. package/src/LoadLayer.res.mjs +13 -55
  24. package/src/Main.res +42 -23
  25. package/src/Main.res.mjs +32 -35
  26. package/src/Metrics.res +943 -66
  27. package/src/Metrics.res.mjs +367 -50
  28. package/src/Persistence.res +3 -0
  29. package/src/PgStorage.res +3 -8
  30. package/src/PgStorage.res.mjs +3 -4
  31. package/src/PruneStaleHistory.res +1 -1
  32. package/src/PruneStaleHistory.res.mjs +1 -2
  33. package/src/Rollback.res +4 -5
  34. package/src/Rollback.res.mjs +2 -3
  35. package/src/SimulateItems.res.mjs +3 -21
  36. package/src/TestIndexer.res.mjs +4 -23
  37. package/src/TestIndexerProxyStorage.res +1 -0
  38. package/src/TestIndexerProxyStorage.res.mjs +1 -1
  39. package/src/Writing.res +3 -5
  40. package/src/Writing.res.mjs +3 -6
  41. package/src/bindings/ClickHouse.res +101 -17
  42. package/src/bindings/ClickHouse.res.mjs +58 -19
  43. package/src/bindings/NodeJs.res +64 -0
  44. package/src/bindings/NodeJs.res.mjs +6 -0
  45. package/src/sources/HyperSyncClient.res +5 -11
  46. package/src/sources/SourceManager.res +72 -27
  47. package/src/sources/SourceManager.res.mjs +81 -11
  48. package/src/sources/SourceManager.resi +14 -0
  49. package/src/tui/Tui.res +1 -1
  50. package/src/tui/Tui.res.mjs +2 -2
  51. package/src/Prometheus.res +0 -789
  52. package/src/Prometheus.res.mjs +0 -847
  53. package/src/bindings/PromClient.res +0 -72
  54. package/src/bindings/PromClient.res.mjs +0 -38
package/src/Metrics.res CHANGED
@@ -1,74 +1,951 @@
1
- // Pull-based metrics computed from live indexer state at scrape time, merged with
2
- // the imperatively-updated prom-client default registry. Used because the address
3
- // index is mutated in place (no single update site to fire a gauge from).
4
-
5
- let indexingAddressesName = "envio_indexing_addresses"
6
- let indexingAddressesHelp = "The number of addresses indexed on chain. Includes both static and dynamic addresses."
7
-
8
- // Render a gauge straight from the per-chain dict, one sample per key, without
9
- // materialising an intermediate samples array. Accumulate into one string rather
10
- // than building a lines array to join: `++` compiles to JS `+=`, which V8 grows
11
- // as a ConsString instead of recopying.
12
- let renderGauge = (~name, ~help, ~chains: dict<'a>, ~value: 'a => int) => {
13
- let out = ref(`# HELP ${name} ${help}\n# TYPE ${name} gauge`)
14
- let prefix = `\n${name}{chainId="`
15
- chains->Utils.Dict.forEachWithKey((chain, chainId) => {
16
- out := out.contents ++ prefix ++ chainId ++ `"} ` ++ value(chain)->Int.toString
17
- })
18
- out.contents
19
- }
20
-
21
- let sourceRequestTotalName = "envio_source_request_total"
22
- let sourceRequestTotalHelp = "The number of requests made to data sources."
23
- let sourceRequestSecondsTotalName = "envio_source_request_seconds_total"
24
- let sourceRequestSecondsTotalHelp = "Cumulative time spent on data source requests."
25
-
26
- // Hand-rolls both HELP/TYPE blocks and their samples straight from
27
- // SourceManager's aggregates — fully our own state, no prom-client registry
28
- // involved. A leading "\n" on each block since, unlike prom-client's own
29
- // metrics() output, renderGauge's output right before this doesn't end in one.
30
- // Skips a method's seconds line when it has no timing (e.g. heightSubscription,
31
- // which only ever records a count), and skips both blocks entirely when there's
32
- // nothing to report at all.
33
- let renderSourceRequests = (~samples: array<SourceManager.requestStatSample>) => {
34
- if samples->Utils.Array.isEmpty {
35
- ""
1
+ type chainMetrics = {
2
+ chainId: float,
3
+ poweredByHyperSync: bool,
4
+ firstEventBlockNumber: option<int>,
5
+ latestProcessedBlock: option<int>,
6
+ timestampCaughtUpToHeadOrEndblock: option<Date.t>,
7
+ numEventsProcessed: float,
8
+ latestFetchedBlockNumber: int,
9
+ // Clamped to endBlock once the chain has processed to it.
10
+ knownHeight: int,
11
+ numBatchesFetched: int,
12
+ startBlock: int,
13
+ endBlock: option<int>,
14
+ numAddresses: int,
15
+ isReady: bool,
16
+ // Raw source height, unlike knownHeight which is clamped to endBlock.
17
+ sourceBlockNumber: int,
18
+ // Raw committed progress (may be -1), unlike the optional latestProcessedBlock.
19
+ progressBlockNumber: int,
20
+ progressLatencyMs: option<int>,
21
+ concurrency: int,
22
+ partitionsCount: int,
23
+ bufferSize: int,
24
+ // Raw buffer block (may be -1), unlike the clamped latestFetchedBlockNumber.
25
+ bufferBlockNumber: int,
26
+ idleSeconds: float,
27
+ waitingForNewBlockSeconds: float,
28
+ queryingSeconds: float,
29
+ blockRangeFetchSeconds: float,
30
+ blockRangeParseSeconds: float,
31
+ blockRangeFetchCount: float,
32
+ blockRangeFetchedEvents: float,
33
+ blockRangeFetchedBlocks: float,
34
+ reorgCount: int,
35
+ reorgDetectedBlock: option<int>,
36
+ rollbackTargetBlock: option<int>,
37
+ }
38
+
39
+ type handlerMetrics = {
40
+ contract: string,
41
+ event: string,
42
+ processingSeconds: float,
43
+ processingCount: float,
44
+ preloadSeconds: float,
45
+ preloadCount: float,
46
+ preloadSecondsTotal: float,
47
+ }
48
+
49
+ type effectMetrics = {
50
+ effect: string,
51
+ scope: string,
52
+ callSeconds: float,
53
+ callSecondsTotal: float,
54
+ callCount: float,
55
+ activeCallsCount: int,
56
+ queueCount: int,
57
+ queueWaitSeconds: float,
58
+ invalidationsCount: float,
59
+ // None for effects that don't persist their cache.
60
+ cacheCount: option<int>,
61
+ }
62
+
63
+ type storageLoadMetrics = {
64
+ operation: string,
65
+ storage: string,
66
+ seconds: float,
67
+ secondsTotal: float,
68
+ count: float,
69
+ whereSize: float,
70
+ size: float,
71
+ }
72
+
73
+ type storageWriteMetrics = {
74
+ storage: string,
75
+ seconds: float,
76
+ count: int,
77
+ }
78
+
79
+ type historyPruneMetrics = {
80
+ entity: string,
81
+ seconds: float,
82
+ count: int,
83
+ }
84
+
85
+ type sourceRequestMetrics = {
86
+ source: string,
87
+ chainId: int,
88
+ method: string,
89
+ count: int,
90
+ seconds: float,
91
+ }
92
+
93
+ type sourceHeightMetrics = {
94
+ source: string,
95
+ chainId: int,
96
+ height: int,
97
+ }
98
+
99
+ type t = {
100
+ startTime: Date.t,
101
+ targetBufferSize: int,
102
+ isInReorgThreshold: bool,
103
+ rollbackEnabled: bool,
104
+ maxBatchSize: int,
105
+ preloadSeconds: float,
106
+ processingSeconds: float,
107
+ rollbackSeconds: float,
108
+ rollbackCount: int,
109
+ rollbackEventsCount: float,
110
+ chains: array<chainMetrics>,
111
+ handlers: array<handlerMetrics>,
112
+ effects: array<effectMetrics>,
113
+ storageLoads: array<storageLoadMetrics>,
114
+ storageWrites: array<storageWriteMetrics>,
115
+ historyPrunes: array<historyPruneMetrics>,
116
+ sourceRequests: array<sourceRequestMetrics>,
117
+ sourceHeights: array<sourceHeightMetrics>,
118
+ }
119
+
120
+ // Prometheus floats keep at most 3 decimals; integral values render without a
121
+ // fractional part.
122
+ @inline
123
+ let formatValue = (value: float) => (Math.round(value *. 1000.) /. 1000.)->Float.toString
124
+
125
+ // Prometheus exposition-format escaping for label values: a raw `"`, `\` or
126
+ // newline in a user-supplied name (contract, event, effect, ...) would
127
+ // otherwise break the scrape.
128
+ let escapeLabelValue = (value: string) =>
129
+ if value->String.includes("\\") || value->String.includes("\"") || value->String.includes("\n") {
130
+ value
131
+ ->String.replaceAll("\\", "\\\\")
132
+ ->String.replaceAll("\"", "\\\"")
133
+ ->String.replaceAll("\n", "\\n")
36
134
  } else {
37
- let countOut = ref(
38
- `\n# HELP ${sourceRequestTotalName} ${sourceRequestTotalHelp}\n# TYPE ${sourceRequestTotalName} counter`,
39
- )
40
- let secondsOut = ref(
41
- `\n# HELP ${sourceRequestSecondsTotalName} ${sourceRequestSecondsTotalHelp}\n# TYPE ${sourceRequestSecondsTotalName} counter`,
42
- )
43
- samples->Array.forEach(sample => {
44
- let labels = `{source="${sample.sourceName}",chainId="${sample.chainId->Int.toString}",method="${sample.method}"}`
45
- countOut :=
46
- countOut.contents ++ `\n${sourceRequestTotalName}${labels} ${sample.count->Int.toString}`
47
- if sample.seconds !== 0. {
48
- secondsOut :=
49
- secondsOut.contents ++
50
- `\n${sourceRequestSecondsTotalName}${labels} ${sample.seconds->Float.toFixed(~digits=3)}`
135
+ value
136
+ }
137
+
138
+ // Accumulate into one string rather than building a lines array to join: `++`
139
+ // compiles to JS `+=`, which V8 grows as a ConsString instead of recopying.
140
+ type builder = {mutable out: string}
141
+
142
+ // Begin a metric: HELP/TYPE header, with a blank line between metrics.
143
+ let block = (b: builder, ~name, ~help, ~kind) => {
144
+ let header = `# HELP ${name} ${help}\n# TYPE ${name} ${kind}`
145
+ b.out = b.out === "" ? header : b.out ++ "\n\n" ++ header
146
+ }
147
+
148
+ let sample = (b: builder, ~name, ~labels="", ~value) =>
149
+ b.out = b.out ++ "\n" ++ name ++ labels ++ " " ++ formatValue(value)
150
+
151
+ // One metric with a sample per entry. Entries carry their pre-rendered label
152
+ // string so the same list feeds several metrics without re-rendering labels.
153
+ let series = (
154
+ b: builder,
155
+ ~name,
156
+ ~help,
157
+ ~kind,
158
+ ~entries: array<(string, 'a)>,
159
+ ~value: 'a => float,
160
+ ) => {
161
+ b->block(~name, ~help, ~kind)
162
+ for i in 0 to entries->Array.length - 1 {
163
+ let (labels, entry) = entries->Array.getUnsafe(i)
164
+ b->sample(~name, ~labels, ~value=value(entry))
165
+ }
166
+ }
167
+
168
+ // Same as series, but an entry can opt out of its sample (e.g. a counter that
169
+ // was never incremented or a gauge that was never observed).
170
+ let seriesOpt = (
171
+ b: builder,
172
+ ~name,
173
+ ~help,
174
+ ~kind,
175
+ ~entries: array<(string, 'a)>,
176
+ ~value: 'a => option<float>,
177
+ ) => {
178
+ b->block(~name, ~help, ~kind)
179
+ for i in 0 to entries->Array.length - 1 {
180
+ let (labels, entry) = entries->Array.getUnsafe(i)
181
+ switch value(entry) {
182
+ | Some(value) => b->sample(~name, ~labels, ~value)
183
+ | None => ()
184
+ }
185
+ }
186
+ }
187
+
188
+ let single = (b: builder, ~name, ~help, ~kind, ~value) => {
189
+ b->block(~name, ~help, ~kind)
190
+ b->sample(~name, ~value)
191
+ }
192
+
193
+ let renderMetrics = (b: builder, metrics: t) => {
194
+ let chains = metrics.chains->Array.map(m => (`{chainId="${m.chainId->Float.toString}"}`, m))
195
+ let handlers =
196
+ metrics.handlers->Array.map(s => (
197
+ `{contract="${s.contract->escapeLabelValue}",event="${s.event->escapeLabelValue}"}`,
198
+ s,
199
+ ))
200
+ let effects =
201
+ metrics.effects->Array.map(s => (
202
+ `{effect="${s.effect->escapeLabelValue}",scope="${s.scope->escapeLabelValue}"}`,
203
+ s,
204
+ ))
205
+ let storageLoads =
206
+ metrics.storageLoads->Array.map(s => (
207
+ `{operation="${s.operation->escapeLabelValue}",storage="${s.storage->escapeLabelValue}"}`,
208
+ s,
209
+ ))
210
+ let storageWrites =
211
+ metrics.storageWrites->Array.map(s => (`{storage="${s.storage->escapeLabelValue}"}`, s))
212
+ let historyPrunes =
213
+ metrics.historyPrunes->Array.map(s => (`{entity="${s.entity->escapeLabelValue}"}`, s))
214
+ // Two sources can share a name (e.g. primary and fallback RPC urls on the
215
+ // same host), so aggregate by label set — duplicate samples would make
216
+ // Prometheus reject the scrape.
217
+ let sourceRequests = {
218
+ let byLabels: dict<sourceRequestMetrics> = Dict.make()
219
+ metrics.sourceRequests->Array.forEach(s => {
220
+ let labels = `{source="${s.source->escapeLabelValue}",chainId="${s.chainId->Int.toString}",method="${s.method->escapeLabelValue}"}`
221
+ switch byLabels->Utils.Dict.dangerouslyGetNonOption(labels) {
222
+ | Some(existing) =>
223
+ byLabels->Dict.set(
224
+ labels,
225
+ {...existing, count: existing.count + s.count, seconds: existing.seconds +. s.seconds},
226
+ )
227
+ | None => byLabels->Dict.set(labels, s)
51
228
  }
52
229
  })
53
- countOut.contents ++ secondsOut.contents
230
+ byLabels->Dict.toArray
54
231
  }
232
+ let sources = {
233
+ let byLabels: dict<int> = Dict.make()
234
+ metrics.sourceHeights->Array.forEach(s => {
235
+ let labels = `{source="${s.source->escapeLabelValue}",chainId="${s.chainId->Int.toString}"}`
236
+ switch byLabels->Utils.Dict.dangerouslyGetNonOption(labels) {
237
+ | Some(existing) if existing >= s.height => ()
238
+ | _ => byLabels->Dict.set(labels, s.height)
239
+ }
240
+ })
241
+ byLabels->Dict.toArray
242
+ }
243
+
244
+ b->single(
245
+ ~name="envio_preload_seconds",
246
+ ~help="Cumulative time spent on preloading entities during batch processing.",
247
+ ~kind="counter",
248
+ ~value=metrics.preloadSeconds,
249
+ )
250
+ b->single(
251
+ ~name="envio_processing_seconds",
252
+ ~help="Cumulative time spent executing event handlers during batch processing.",
253
+ ~kind="counter",
254
+ ~value=metrics.processingSeconds,
255
+ )
256
+ b->series(
257
+ ~name="envio_progress_ready",
258
+ ~help="Whether the chain is fully synced to the head.",
259
+ ~kind="gauge",
260
+ ~entries=chains,
261
+ ~value=m => m.isReady ? 1. : 0.,
262
+ )
263
+ // Keep legacy metric name for backward compatibility
264
+ b->single(
265
+ ~name="hyperindex_synced_to_head",
266
+ ~help="All chains fully synced",
267
+ ~kind="gauge",
268
+ ~value=metrics.chains->Utils.Array.notEmpty && metrics.chains->Array.every(m => m.isReady)
269
+ ? 1.
270
+ : 0.,
271
+ )
272
+ b->series(
273
+ ~name="envio_processing_handler_seconds",
274
+ ~help="Cumulative time spent inside individual event handler executions.",
275
+ ~kind="counter",
276
+ ~entries=handlers,
277
+ ~value=s => s.processingSeconds,
278
+ )
279
+ b->series(
280
+ ~name="envio_processing_handler_total",
281
+ ~help="Total number of individual event handler executions.",
282
+ ~kind="counter",
283
+ ~entries=handlers,
284
+ ~value=s => s.processingCount,
285
+ )
286
+ b->series(
287
+ ~name="envio_preload_handler_seconds",
288
+ ~help="Wall-clock time spent inside individual preload handler executions.",
289
+ ~kind="counter",
290
+ ~entries=handlers,
291
+ ~value=s => s.preloadSeconds,
292
+ )
293
+ b->series(
294
+ ~name="envio_preload_handler_total",
295
+ ~help="Total number of individual preload handler executions.",
296
+ ~kind="counter",
297
+ ~entries=handlers,
298
+ ~value=s => s.preloadCount,
299
+ )
300
+ b->series(
301
+ ~name="envio_preload_handler_seconds_total",
302
+ ~help="Cumulative time spent inside individual preload handler executions. Can exceed wall-clock time due to parallel execution.",
303
+ ~kind="counter",
304
+ ~entries=handlers,
305
+ ~value=s => s.preloadSecondsTotal,
306
+ )
307
+ b->series(
308
+ ~name="envio_fetching_block_range_seconds",
309
+ ~help="Cumulative time spent fetching block ranges.",
310
+ ~kind="counter",
311
+ ~entries=chains,
312
+ ~value=m => m.blockRangeFetchSeconds,
313
+ )
314
+ b->series(
315
+ ~name="envio_fetching_block_range_parse_seconds",
316
+ ~help="Cumulative time spent parsing block range fetch responses.",
317
+ ~kind="counter",
318
+ ~entries=chains,
319
+ ~value=m => m.blockRangeParseSeconds,
320
+ )
321
+ b->series(
322
+ ~name="envio_fetching_block_range_total",
323
+ ~help="Total number of block range fetch operations.",
324
+ ~kind="counter",
325
+ ~entries=chains,
326
+ ~value=m => m.blockRangeFetchCount,
327
+ )
328
+ b->series(
329
+ ~name="envio_fetching_block_range_events_total",
330
+ ~help="Cumulative number of events fetched across all block range operations.",
331
+ ~kind="counter",
332
+ ~entries=chains,
333
+ ~value=m => m.blockRangeFetchedEvents,
334
+ )
335
+ b->series(
336
+ ~name="envio_fetching_block_range_size",
337
+ ~help="Cumulative number of blocks covered across all block range fetch operations.",
338
+ ~kind="counter",
339
+ ~entries=chains,
340
+ ~value=m => m.blockRangeFetchedBlocks,
341
+ )
342
+ b->series(
343
+ ~name="envio_indexing_known_height",
344
+ ~help="The latest known block number reported by the active indexing source. This value may lag behind the actual chain height, as it is updated only when needed.",
345
+ ~kind="gauge",
346
+ ~entries=chains,
347
+ ~value=m => m.sourceBlockNumber->Int.toFloat,
348
+ )
349
+ b->single(
350
+ ~name="envio_process_start_time_seconds",
351
+ ~help="Start time of the process since unix epoch in seconds.",
352
+ ~kind="gauge",
353
+ ~value=metrics.startTime->Date.getTime /. 1000.,
354
+ )
355
+ b->series(
356
+ ~name="envio_indexing_concurrency",
357
+ ~help="The number of executing concurrent queries to the chain data-source.",
358
+ ~kind="gauge",
359
+ ~entries=chains,
360
+ ~value=m => m.concurrency->Int.toFloat,
361
+ )
362
+ b->series(
363
+ ~name="envio_indexing_partitions",
364
+ ~help="The number of partitions used to split fetching logic by addresses and block ranges.",
365
+ ~kind="gauge",
366
+ ~entries=chains,
367
+ ~value=m => m.partitionsCount->Int.toFloat,
368
+ )
369
+ b->series(
370
+ ~name="envio_indexing_idle_seconds",
371
+ ~help="The time the indexer source syncing has been idle. A high value may indicate the source sync is a bottleneck.",
372
+ ~kind="counter",
373
+ ~entries=chains,
374
+ ~value=m => m.idleSeconds,
375
+ )
376
+ b->series(
377
+ ~name="envio_indexing_source_waiting_seconds",
378
+ ~help="The time the indexer has been waiting for new blocks.",
379
+ ~kind="counter",
380
+ ~entries=chains,
381
+ ~value=m => m.waitingForNewBlockSeconds,
382
+ )
383
+ b->series(
384
+ ~name="envio_indexing_source_querying_seconds",
385
+ ~help="The time spent performing queries to the chain data-source.",
386
+ ~kind="counter",
387
+ ~entries=chains,
388
+ ~value=m => m.queryingSeconds,
389
+ )
390
+ b->series(
391
+ ~name="envio_indexing_buffer_size",
392
+ ~help="The current number of items in the indexing buffer.",
393
+ ~kind="gauge",
394
+ ~entries=chains,
395
+ ~value=m => m.bufferSize->Int.toFloat,
396
+ )
397
+ b->single(
398
+ ~name="envio_indexing_target_buffer_size",
399
+ ~help="The indexer-wide target buffer size shared across all chains. The actual number of items in the queue may exceed this value, but the indexer always tries to keep the buffer filled up to this target.",
400
+ ~kind="gauge",
401
+ ~value=metrics.targetBufferSize->Int.toFloat,
402
+ )
403
+ b->series(
404
+ ~name="envio_indexing_buffer_block",
405
+ ~help="The highest block number that has been fully fetched by the indexer.",
406
+ ~kind="gauge",
407
+ ~entries=chains,
408
+ ~value=m => m.bufferBlockNumber->Int.toFloat,
409
+ )
410
+ b->seriesOpt(
411
+ ~name="envio_indexing_end_block",
412
+ ~help="The block number to stop indexing at. (inclusive)",
413
+ ~kind="gauge",
414
+ ~entries=chains,
415
+ ~value=m => m.endBlock->Option.map(Int.toFloat),
416
+ )
417
+ b->series(
418
+ ~name="envio_source_request_total",
419
+ ~help="The number of requests made to data sources.",
420
+ ~kind="counter",
421
+ ~entries=sourceRequests,
422
+ ~value=s => s.count->Int.toFloat,
423
+ )
424
+ // Skips a method's seconds line when it has no timing (e.g. heightSubscription,
425
+ // which only ever records a count).
426
+ b->seriesOpt(
427
+ ~name="envio_source_request_seconds_total",
428
+ ~help="Cumulative time spent on data source requests.",
429
+ ~kind="counter",
430
+ ~entries=sourceRequests,
431
+ ~value=s => s.seconds !== 0. ? Some(s.seconds) : None,
432
+ )
433
+ b->series(
434
+ ~name="envio_source_known_height",
435
+ ~help="The latest known block number reported by the source. This value may lag behind the actual chain height, as it is updated only when queried.",
436
+ ~kind="gauge",
437
+ ~entries=sources,
438
+ ~value=height => height->Int.toFloat,
439
+ )
440
+ b->seriesOpt(
441
+ ~name="envio_reorg_detected_total",
442
+ ~help="Total number of reorgs detected",
443
+ ~kind="counter",
444
+ ~entries=chains,
445
+ ~value=m => m.reorgCount > 0 ? Some(m.reorgCount->Int.toFloat) : None,
446
+ )
447
+ b->seriesOpt(
448
+ ~name="envio_reorg_detected_block",
449
+ ~help="The block number where reorg was detected the last time. This doesn't mean that the block was reorged, this is simply where we found block hash to be different.",
450
+ ~kind="gauge",
451
+ ~entries=chains,
452
+ ~value=m => m.reorgDetectedBlock->Option.map(Int.toFloat),
453
+ )
454
+ b->single(
455
+ ~name="envio_reorg_threshold",
456
+ ~help="Whether indexing is currently within the reorg threshold",
457
+ ~kind="gauge",
458
+ ~value=metrics.isInReorgThreshold ? 1. : 0.,
459
+ )
460
+ b->single(
461
+ ~name="envio_rollback_enabled",
462
+ ~help="Whether rollback on reorg is enabled",
463
+ ~kind="gauge",
464
+ ~value=metrics.rollbackEnabled ? 1. : 0.,
465
+ )
466
+ b->single(
467
+ ~name="envio_rollback_seconds",
468
+ ~help="Rollback on reorg total time.",
469
+ ~kind="counter",
470
+ ~value=metrics.rollbackSeconds,
471
+ )
472
+ b->single(
473
+ ~name="envio_rollback_total",
474
+ ~help="Number of successful rollbacks on reorg",
475
+ ~kind="counter",
476
+ ~value=metrics.rollbackCount->Int.toFloat,
477
+ )
478
+ b->single(
479
+ ~name="envio_rollback_events",
480
+ ~help="Number of events rollbacked on reorg",
481
+ ~kind="counter",
482
+ ~value=metrics.rollbackEventsCount,
483
+ )
484
+ b->series(
485
+ ~name="envio_rollback_history_prune_seconds",
486
+ ~help="The total time spent pruning entity history which is not in the reorg threshold.",
487
+ ~kind="counter",
488
+ ~entries=historyPrunes,
489
+ ~value=s => s.seconds,
490
+ )
491
+ b->series(
492
+ ~name="envio_rollback_history_prune_total",
493
+ ~help="Number of successful entity history prunes",
494
+ ~kind="counter",
495
+ ~entries=historyPrunes,
496
+ ~value=s => s.count->Int.toFloat,
497
+ )
498
+ b->seriesOpt(
499
+ ~name="envio_rollback_target_block",
500
+ ~help="The block number reorg was rollbacked to the last time.",
501
+ ~kind="gauge",
502
+ ~entries=chains,
503
+ ~value=m => m.rollbackTargetBlock->Option.map(Int.toFloat),
504
+ )
505
+ b->single(
506
+ ~name="envio_processing_max_batch_size",
507
+ ~help="The maximum number of items to process in a single batch.",
508
+ ~kind="gauge",
509
+ ~value=metrics.maxBatchSize->Int.toFloat,
510
+ )
511
+ b->series(
512
+ ~name="envio_progress_block",
513
+ ~help="The block number of the latest block processed and stored in the database.",
514
+ ~kind="gauge",
515
+ ~entries=chains,
516
+ ~value=m => m.progressBlockNumber->Int.toFloat,
517
+ )
518
+ b->series(
519
+ ~name="envio_progress_events",
520
+ ~help="The number of events processed and reflected in the database.",
521
+ ~kind="gauge",
522
+ ~entries=chains,
523
+ ~value=m => m.numEventsProcessed,
524
+ )
525
+ b->seriesOpt(
526
+ ~name="envio_progress_latency",
527
+ ~help="The latency in milliseconds between the latest processed event creation and the time it was written to storage.",
528
+ ~kind="gauge",
529
+ ~entries=chains,
530
+ ~value=m => m.progressLatencyMs->Option.map(Int.toFloat),
531
+ )
532
+ // Effects that were never called (e.g. seeded from the persisted cache only)
533
+ // get no call samples.
534
+ let ifCalled = (s: effectMetrics, value) =>
535
+ s.callCount > 0. || s.activeCallsCount > 0 ? Some(value) : None
536
+ b->seriesOpt(
537
+ ~name="envio_effect_call_seconds",
538
+ ~help="Processing time taken to call the Effect function.",
539
+ ~kind="counter",
540
+ ~entries=effects,
541
+ ~value=s => s->ifCalled(s.callSeconds),
542
+ )
543
+ b->seriesOpt(
544
+ ~name="envio_effect_call_seconds_total",
545
+ ~help="Cumulative time spent calling the Effect function during the indexing process.",
546
+ ~kind="counter",
547
+ ~entries=effects,
548
+ ~value=s => s->ifCalled(s.callSecondsTotal),
549
+ )
550
+ b->seriesOpt(
551
+ ~name="envio_effect_call_total",
552
+ ~help="Cumulative number of resolved Effect function calls during the indexing process.",
553
+ ~kind="counter",
554
+ ~entries=effects,
555
+ ~value=s => s->ifCalled(s.callCount),
556
+ )
557
+ b->seriesOpt(
558
+ ~name="envio_effect_active_calls",
559
+ ~help="The number of Effect function calls that are currently running.",
560
+ ~kind="gauge",
561
+ ~entries=effects,
562
+ ~value=s => s->ifCalled(s.activeCallsCount->Int.toFloat),
563
+ )
564
+ // Only effects that persist their cache get a sample (including a zero for
565
+ // an existing but empty persisted table); a plain effect gets none.
566
+ b->seriesOpt(
567
+ ~name="envio_effect_cache",
568
+ ~help="The number of items in the effect cache.",
569
+ ~kind="gauge",
570
+ ~entries=effects,
571
+ ~value=s => s.cacheCount->Option.map(Int.toFloat),
572
+ )
573
+ // Unlike the rest of the effect metrics, invalidations and queue waits keep
574
+ // the effect-only label set, aggregated across scopes.
575
+ let effectTotals = {
576
+ let byEffect = Dict.make()
577
+ metrics.effects->Array.forEach(s => {
578
+ switch byEffect->Utils.Dict.dangerouslyGetNonOption(s.effect) {
579
+ | Some((invalidations, queueWaitSeconds)) =>
580
+ byEffect->Dict.set(
581
+ s.effect,
582
+ (invalidations +. s.invalidationsCount, queueWaitSeconds +. s.queueWaitSeconds),
583
+ )
584
+ | None => byEffect->Dict.set(s.effect, (s.invalidationsCount, s.queueWaitSeconds))
585
+ }
586
+ })
587
+ let entries = []
588
+ byEffect->Utils.Dict.forEachWithKey((totals, effect) =>
589
+ entries->Array.push((`{effect="${effect->escapeLabelValue}"}`, totals))
590
+ )
591
+ entries
592
+ }
593
+ b->seriesOpt(
594
+ ~name="envio_effect_cache_invalidations",
595
+ ~help="The number of effect cache invalidations.",
596
+ ~kind="counter",
597
+ ~entries=effectTotals,
598
+ ~value=((invalidations, _)) => invalidations > 0. ? Some(invalidations) : None,
599
+ )
600
+ b->seriesOpt(
601
+ ~name="envio_effect_queue",
602
+ ~help="The number of effect calls waiting in the rate limit queue.",
603
+ ~kind="gauge",
604
+ ~entries=effects,
605
+ ~value=s =>
606
+ s.queueCount > 0 || s.queueWaitSeconds > 0. ? Some(s.queueCount->Int.toFloat) : None,
607
+ )
608
+ b->seriesOpt(
609
+ ~name="envio_effect_queue_wait_seconds",
610
+ ~help="The time spent waiting in the rate limit queue.",
611
+ ~kind="counter",
612
+ ~entries=effectTotals,
613
+ ~value=((_, queueWaitSeconds)) => queueWaitSeconds !== 0. ? Some(queueWaitSeconds) : None,
614
+ )
615
+ b->series(
616
+ ~name="envio_storage_load_seconds",
617
+ ~help="Processing time taken to load data from storage.",
618
+ ~kind="counter",
619
+ ~entries=storageLoads,
620
+ ~value=s => s.seconds,
621
+ )
622
+ b->series(
623
+ ~name="envio_storage_load_seconds_total",
624
+ ~help="Cumulative time spent loading data from storage during the indexing process.",
625
+ ~kind="counter",
626
+ ~entries=storageLoads,
627
+ ~value=s => s.secondsTotal,
628
+ )
629
+ b->series(
630
+ ~name="envio_storage_load_total",
631
+ ~help="Cumulative number of successful storage load operations during the indexing process.",
632
+ ~kind="counter",
633
+ ~entries=storageLoads,
634
+ ~value=s => s.count,
635
+ )
636
+ b->series(
637
+ ~name="envio_storage_load_where_size",
638
+ ~help="Cumulative number of filter conditions ('where' items) used in storage load operations during the indexing process.",
639
+ ~kind="counter",
640
+ ~entries=storageLoads,
641
+ ~value=s => s.whereSize,
642
+ )
643
+ b->series(
644
+ ~name="envio_storage_load_size",
645
+ ~help="Cumulative number of records loaded from storage during the indexing process.",
646
+ ~kind="counter",
647
+ ~entries=storageLoads,
648
+ ~value=s => s.size,
649
+ )
650
+ b->series(
651
+ ~name="envio_storage_write_seconds",
652
+ ~help="Cumulative time spent writing batch data to storage.",
653
+ ~kind="counter",
654
+ ~entries=storageWrites,
655
+ ~value=s => s.seconds,
656
+ )
657
+ b->series(
658
+ ~name="envio_storage_write_total",
659
+ ~help="Cumulative number of successful storage write operations during the indexing process.",
660
+ ~kind="counter",
661
+ ~entries=storageWrites,
662
+ ~value=s => s.count->Int.toFloat,
663
+ )
664
+ b->series(
665
+ ~name="envio_indexing_addresses",
666
+ ~help="The number of addresses indexed on chain. Includes both static and dynamic addresses.",
667
+ ~kind="gauge",
668
+ ~entries=chains,
669
+ ~value=m => m.numAddresses->Int.toFloat,
670
+ )
55
671
  }
56
672
 
57
- let collect = async (~state: option<IndexerState.t>) => {
58
- let base = await PromClient.defaultRegister->PromClient.metrics
59
- switch state {
60
- | None => base
61
- | Some(state) =>
62
- let chainStates = state->IndexerState.chainStates
63
- let sourceRequestSamples =
64
- chainStates
65
- ->Dict.valuesToArray
66
- ->Array.flatMap(cs => cs->ChainState.sourceManager->SourceManager.getRequestStatSamples)
67
- `${base}${renderGauge(
68
- ~name=indexingAddressesName,
69
- ~help=indexingAddressesHelp,
70
- ~chains=chainStates,
71
- ~value=cs => (cs->ChainState.toChainData).numAddresses,
72
- )}${renderSourceRequests(~samples=sourceRequestSamples)}\n`
673
+ let contentType = "text/plain; version=0.0.4; charset=utf-8"
674
+
675
+ let collect = (~metrics: option<t>) => {
676
+ let b = {out: ""}
677
+ b->series(
678
+ ~name="envio_info",
679
+ ~help="Information about the indexer",
680
+ ~kind="gauge",
681
+ ~entries=[(`{version="${Utils.EnvioPackage.value.version->escapeLabelValue}"}`, ())],
682
+ ~value=() => 1.,
683
+ )
684
+ switch metrics {
685
+ | Some(metrics) => b->renderMetrics(metrics)
686
+ | None => ()
687
+ }
688
+ b.out ++ "\n"
689
+ }
690
+
691
+ // Node.js runtime metrics for /metrics/runtime, read from process/perf_hooks
692
+ // at scrape time. Uses the same metric names prom-client's default collectors
693
+ // exposed here before, so existing dashboards keep working.
694
+
695
+ type gcStat = {mutable count: float, mutable seconds: float}
696
+ type runtimeCollectors = {
697
+ eventLoopDelay: NodeJs.PerfHooks.intervalHistogram,
698
+ // Cumulative GC pause count/time per kind, fed by a "gc" PerformanceObserver.
699
+ gcStats: dict<gcStat>,
700
+ // Fixed at collector start; recomputing per scrape lets rounding/clock jitter
701
+ // move it and read as a phantom restart.
702
+ processStartTimeSeconds: float,
703
+ }
704
+
705
+ // perf_hooks GC kind constants: NODE_PERFORMANCE_GC_{MINOR,MAJOR,INCREMENTAL,WEAKCB}.
706
+ let gcKindName = kind =>
707
+ switch kind {
708
+ | 1 => "minor"
709
+ | 4 => "major"
710
+ | 8 => "incremental"
711
+ | 16 => "weakcb"
712
+ | _ => "unknown"
713
+ }
714
+
715
+ // Started on the first scrape rather than at module load, so importing envio
716
+ // (e.g. from the CLI) never spins up samplers.
717
+ let runtimeCollectors = ref(None)
718
+
719
+ let getRuntimeCollectors = () =>
720
+ switch runtimeCollectors.contents {
721
+ | Some(collectors) => collectors
722
+ | None =>
723
+ let eventLoopDelay = NodeJs.PerfHooks.monitorEventLoopDelay(~options={resolution: 10})
724
+ let _ = eventLoopDelay->NodeJs.PerfHooks.enable
725
+ let gcStats = Dict.make()
726
+ let observer = NodeJs.PerfHooks.makePerformanceObserver(list =>
727
+ list
728
+ ->NodeJs.PerfHooks.getEntries
729
+ ->Array.forEach(entry => {
730
+ let kind = gcKindName(entry.detail["kind"])
731
+ let stat = switch gcStats->Utils.Dict.dangerouslyGetNonOption(kind) {
732
+ | Some(stat) => stat
733
+ | None =>
734
+ let stat = {count: 0., seconds: 0.}
735
+ gcStats->Dict.set(kind, stat)
736
+ stat
737
+ }
738
+ stat.count = stat.count +. 1.
739
+ stat.seconds = stat.seconds +. entry.duration /. 1000.
740
+ })
741
+ )
742
+ observer->NodeJs.PerfHooks.observe({entryTypes: ["gc"]})
743
+ let collectors = {
744
+ eventLoopDelay,
745
+ gcStats,
746
+ processStartTimeSeconds: Date.now() /. 1000. -. NodeJs.Process.uptime(),
747
+ }
748
+ runtimeCollectors := Some(collectors)
749
+ collectors
750
+ }
751
+
752
+ // Called at server startup so GC pauses and event-loop delays are covered
753
+ // from the beginning of the run, not from the first scrape.
754
+ let startRuntimeCollectors = () => getRuntimeCollectors()->ignore
755
+
756
+ let collectRuntime = () => {
757
+ let b = {out: ""}
758
+ let memory = NodeJs.Process.memoryUsage()
759
+ let cpu = NodeJs.Process.cpuUsage()
760
+ let elu = NodeJs.PerfHooks.performance->NodeJs.PerfHooks.eventLoopUtilization
761
+ let {eventLoopDelay, gcStats, processStartTimeSeconds} = getRuntimeCollectors()
762
+ b->single(
763
+ ~name="process_cpu_user_seconds_total",
764
+ ~help="Total user CPU time spent in seconds.",
765
+ ~kind="counter",
766
+ ~value=cpu.user /. 1_000_000.,
767
+ )
768
+ b->single(
769
+ ~name="process_cpu_system_seconds_total",
770
+ ~help="Total system CPU time spent in seconds.",
771
+ ~kind="counter",
772
+ ~value=cpu.system /. 1_000_000.,
773
+ )
774
+ b->single(
775
+ ~name="process_cpu_seconds_total",
776
+ ~help="Total user and system CPU time spent in seconds.",
777
+ ~kind="counter",
778
+ ~value=(cpu.user +. cpu.system) /. 1_000_000.,
779
+ )
780
+ b->single(
781
+ ~name="process_start_time_seconds",
782
+ ~help="Start time of the process since unix epoch in seconds.",
783
+ ~kind="gauge",
784
+ ~value=processStartTimeSeconds,
785
+ )
786
+ b->single(
787
+ ~name="process_resident_memory_bytes",
788
+ ~help="Resident memory size in bytes.",
789
+ ~kind="gauge",
790
+ ~value=memory.rss,
791
+ )
792
+ b->single(
793
+ ~name="nodejs_heap_size_total_bytes",
794
+ ~help="Process heap size from Node.js in bytes.",
795
+ ~kind="gauge",
796
+ ~value=memory.heapTotal,
797
+ )
798
+ b->single(
799
+ ~name="nodejs_heap_size_used_bytes",
800
+ ~help="Process heap size used from Node.js in bytes.",
801
+ ~kind="gauge",
802
+ ~value=memory.heapUsed,
803
+ )
804
+ b->single(
805
+ ~name="nodejs_external_memory_bytes",
806
+ ~help="Node.js external memory size in bytes.",
807
+ ~kind="gauge",
808
+ ~value=memory.external_,
809
+ )
810
+ b->single(
811
+ ~name="nodejs_eventloop_utilization",
812
+ ~help="Ratio of time the event loop is active, since process start.",
813
+ ~kind="gauge",
814
+ ~value=elu.utilization,
815
+ )
816
+ // Nanoseconds in the histogram; reset after rendering so each scrape reports
817
+ // the delay distribution since the previous one, matching prom-client. With
818
+ // no samples yet (e.g. the first scrape, which starts the sampler) the
819
+ // histogram reports NaN means and a sentinel min — render 0 instead.
820
+ let hasLagSamples = eventLoopDelay.max > 0.
821
+ let nsToSeconds = ns => hasLagSamples && !(ns->Float.isNaN) ? ns /. 1_000_000_000. : 0.
822
+ b->single(
823
+ ~name="nodejs_eventloop_lag_mean_seconds",
824
+ ~help="The mean of the recorded event loop delays.",
825
+ ~kind="gauge",
826
+ ~value=eventLoopDelay.mean->nsToSeconds,
827
+ )
828
+ b->single(
829
+ ~name="nodejs_eventloop_lag_min_seconds",
830
+ ~help="The minimum recorded event loop delay.",
831
+ ~kind="gauge",
832
+ ~value=eventLoopDelay.min->nsToSeconds,
833
+ )
834
+ b->single(
835
+ ~name="nodejs_eventloop_lag_max_seconds",
836
+ ~help="The maximum recorded event loop delay.",
837
+ ~kind="gauge",
838
+ ~value=eventLoopDelay.max->nsToSeconds,
839
+ )
840
+ b->single(
841
+ ~name="nodejs_eventloop_lag_stddev_seconds",
842
+ ~help="The standard deviation of the recorded event loop delays.",
843
+ ~kind="gauge",
844
+ ~value=eventLoopDelay.stddev->nsToSeconds,
845
+ )
846
+ b->single(
847
+ ~name="nodejs_eventloop_lag_p50_seconds",
848
+ ~help="The 50th percentile of the recorded event loop delays.",
849
+ ~kind="gauge",
850
+ ~value=eventLoopDelay->NodeJs.PerfHooks.percentile(50)->nsToSeconds,
851
+ )
852
+ b->single(
853
+ ~name="nodejs_eventloop_lag_p90_seconds",
854
+ ~help="The 90th percentile of the recorded event loop delays.",
855
+ ~kind="gauge",
856
+ ~value=eventLoopDelay->NodeJs.PerfHooks.percentile(90)->nsToSeconds,
857
+ )
858
+ b->single(
859
+ ~name="nodejs_eventloop_lag_p99_seconds",
860
+ ~help="The 99th percentile of the recorded event loop delays.",
861
+ ~kind="gauge",
862
+ ~value=eventLoopDelay->NodeJs.PerfHooks.percentile(99)->nsToSeconds,
863
+ )
864
+ eventLoopDelay->NodeJs.PerfHooks.reset
865
+ let heapSpaces =
866
+ NodeJs.V8.getHeapSpaceStatistics()->Array.map(s => (
867
+ `{space="${s.spaceName->String.replace("_space", "")}"}`,
868
+ s,
869
+ ))
870
+ b->series(
871
+ ~name="nodejs_heap_space_size_total_bytes",
872
+ ~help="Process heap space size total from Node.js in bytes.",
873
+ ~kind="gauge",
874
+ ~entries=heapSpaces,
875
+ ~value=s => s.spaceSize,
876
+ )
877
+ b->series(
878
+ ~name="nodejs_heap_space_size_used_bytes",
879
+ ~help="Process heap space size used from Node.js in bytes.",
880
+ ~kind="gauge",
881
+ ~entries=heapSpaces,
882
+ ~value=s => s.spaceUsedSize,
883
+ )
884
+ b->series(
885
+ ~name="nodejs_heap_space_size_available_bytes",
886
+ ~help="Process heap space size available from Node.js in bytes.",
887
+ ~kind="gauge",
888
+ ~entries=heapSpaces,
889
+ ~value=s => s.spaceAvailableSize,
890
+ )
891
+ let activeResources = {
892
+ let byType = Dict.make()
893
+ NodeJs.Process.getActiveResourcesInfo()->Array.forEach(resource => {
894
+ let label = `{type="${resource->escapeLabelValue}"}`
895
+ byType->Dict.set(
896
+ label,
897
+ byType->Utils.Dict.dangerouslyGetNonOption(label)->Option.getOr(0.) +. 1.,
898
+ )
899
+ })
900
+ byType->Dict.toArray
73
901
  }
902
+ b->series(
903
+ ~name="nodejs_active_resources",
904
+ ~help="Number of active resources that are currently keeping the event loop alive, grouped by async resource type.",
905
+ ~kind="gauge",
906
+ ~entries=activeResources,
907
+ ~value=count => count,
908
+ )
909
+ b->single(
910
+ ~name="nodejs_active_resources_total",
911
+ ~help="Total number of active resources.",
912
+ ~kind="gauge",
913
+ ~value=activeResources->Array.reduce(0., (acc, (_, count)) => acc +. count),
914
+ )
915
+ let gcEntries = []
916
+ gcStats->Utils.Dict.forEachWithKey((stat, kind) =>
917
+ gcEntries->Array.push((`{kind="${kind}"}`, stat))
918
+ )
919
+ b->series(
920
+ ~name="nodejs_gc_duration_seconds_sum",
921
+ ~help="Cumulative garbage collection pause time by kind, one of major, minor, incremental or weakcb.",
922
+ ~kind="counter",
923
+ ~entries=gcEntries,
924
+ ~value=s => s.seconds,
925
+ )
926
+ b->series(
927
+ ~name="nodejs_gc_duration_seconds_count",
928
+ ~help="Number of garbage collection pauses by kind, one of major, minor, incremental or weakcb.",
929
+ ~kind="counter",
930
+ ~entries=gcEntries,
931
+ ~value=s => s.count,
932
+ )
933
+ let version = NodeJs.Process.version
934
+ let versionParts = version->String.replace("v", "")->String.split(".")
935
+ let versionPart = i => versionParts->Array.get(i)->Option.getOr("0")
936
+ b->series(
937
+ ~name="nodejs_version_info",
938
+ ~help="Node.js version info.",
939
+ ~kind="gauge",
940
+ ~entries=[
941
+ (
942
+ `{version="${version}",major="${versionPart(0)}",minor="${versionPart(
943
+ 1,
944
+ )}",patch="${versionPart(2)}"}`,
945
+ (),
946
+ ),
947
+ ],
948
+ ~value=() => 1.,
949
+ )
950
+ b.out ++ "\n"
74
951
  }