pg_eventstore 3.0.1 → 3.1.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -8
- data/README.md +1 -0
- data/docs/admin_ui.md +5 -0
- data/docs/metrics.md +233 -0
- data/lib/pg_eventstore/chunks/subscription_events_index_chunk.rb +1 -3
- data/lib/pg_eventstore/queries/replica_queries.rb +8 -5
- data/lib/pg_eventstore/sql_builder.rb +1 -1
- data/lib/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rb +1 -1
- data/lib/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rb +31 -0
- data/lib/pg_eventstore/subscriptions/runner_recovery_strategies.rb +1 -0
- data/lib/pg_eventstore/subscriptions/subscriptions_manager.rb +5 -0
- data/lib/pg_eventstore/utils.rb +2 -0
- data/lib/pg_eventstore/version.rb +1 -1
- data/lib/pg_eventstore/web/application.rb +7 -0
- data/lib/pg_eventstore/web/metrics/application.rb +64 -0
- data/lib/pg_eventstore/web/metrics/collectors/base.rb +69 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_health.rb +78 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_latency.rb +161 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rb +59 -0
- data/lib/pg_eventstore/web/metrics/formatter.rb +45 -0
- data/lib/pg_eventstore/web/metrics/helpers.rb +30 -0
- data/lib/pg_eventstore/web/metrics/metric_family.rb +40 -0
- data/lib/pg_eventstore/web/paginator/helpers.rb +27 -23
- data/lib/pg_eventstore/web/views/home/partials/events.erb +8 -6
- data/lib/pg_eventstore/web/views/home/partials/stream_filter.erb +1 -1
- data/lib/pg_eventstore/web/views/streams/partials/streams.erb +1 -1
- data/lib/pg_eventstore/web.rb +8 -0
- data/rbs_collection.lock.yaml +1 -1
- data/sig/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rbs +15 -0
- data/sig/pg_eventstore/web/application.rbs +2 -0
- data/sig/pg_eventstore/web/metrics/application.rbs +11 -0
- data/sig/pg_eventstore/web/metrics/collectors/base.rbs +33 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_health.rbs +15 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_latency.rbs +31 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rbs +15 -0
- data/sig/pg_eventstore/web/metrics/formatter.rbs +19 -0
- data/sig/pg_eventstore/web/metrics/helpers.rbs +11 -0
- data/sig/pg_eventstore/web/metrics/metric_family.rbs +19 -0
- data/sig/pg_eventstore/web/paginator/helpers.rbs +5 -3
- metadata +20 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# Liveness and error state of each reported subscription.
|
|
8
|
+
#
|
|
9
|
+
# The state column alone can not be trusted: a subscription killed without a graceful shutdown keeps
|
|
10
|
+
# state "running" and its lock forever. heartbeat_age_seconds is the discriminator - a subscription is
|
|
11
|
+
# really running only while its heartbeat stays below
|
|
12
|
+
# {PgEventstore::SubscriptionsLifecycle::HEARTBEAT_INTERVAL}.
|
|
13
|
+
class SubscriptionsHealth < Base
|
|
14
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
15
|
+
def call
|
|
16
|
+
state = MetricFamily.new(
|
|
17
|
+
name: 'pg_eventstore_subscription_state',
|
|
18
|
+
type: 'gauge',
|
|
19
|
+
help: 'Last recorded state of the subscription. May be stale - correlate with ' \
|
|
20
|
+
'pg_eventstore_subscription_heartbeat_age_seconds.'
|
|
21
|
+
)
|
|
22
|
+
locked = MetricFamily.new(
|
|
23
|
+
name: 'pg_eventstore_subscription_locked',
|
|
24
|
+
type: 'gauge',
|
|
25
|
+
help: 'Whether the subscription is locked by a subscriptions set.'
|
|
26
|
+
)
|
|
27
|
+
heartbeat_age = MetricFamily.new(
|
|
28
|
+
name: 'pg_eventstore_subscription_heartbeat_age_seconds',
|
|
29
|
+
type: 'gauge',
|
|
30
|
+
help: 'Seconds since the subscription row was last touched by its runner. A locked subscription ' \
|
|
31
|
+
'with a stale heartbeat is a dead process that did not shut down gracefully.'
|
|
32
|
+
)
|
|
33
|
+
restarts = MetricFamily.new(
|
|
34
|
+
name: 'pg_eventstore_subscription_restarts_total',
|
|
35
|
+
type: 'counter',
|
|
36
|
+
help: 'Number of times the subscription was restarted after a failure.'
|
|
37
|
+
)
|
|
38
|
+
last_error_age = MetricFamily.new(
|
|
39
|
+
name: 'pg_eventstore_subscription_last_error_age_seconds',
|
|
40
|
+
type: 'gauge',
|
|
41
|
+
help: 'Seconds since the last error occurred. Absent when the subscription never failed.'
|
|
42
|
+
)
|
|
43
|
+
subscription_rows.each do |row|
|
|
44
|
+
labels = subscription_labels(row)
|
|
45
|
+
state.add_sample(labels: labels.merge(state: row['state']), value: 1)
|
|
46
|
+
locked.add_sample(labels:, value: row['locked'])
|
|
47
|
+
heartbeat_age.add_sample(labels:, value: row['heartbeat_age_seconds'])
|
|
48
|
+
restarts.add_sample(labels:, value: row['restart_count'])
|
|
49
|
+
last_error_age.add_sample(labels:, value: row['last_error_age_seconds']) if row['last_error_age_seconds']
|
|
50
|
+
end
|
|
51
|
+
[state, locked, heartbeat_age, restarts, last_error_age]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @return [Array<Hash>]
|
|
57
|
+
def subscription_rows
|
|
58
|
+
builder = subscriptions_sql_builder
|
|
59
|
+
builder.select(<<~SQL)
|
|
60
|
+
s.set,
|
|
61
|
+
s.name,
|
|
62
|
+
s.state,
|
|
63
|
+
(s.locked_by is not null)::int as locked,
|
|
64
|
+
extract(epoch from ((now() at time zone 'utc') - s.updated_at))::float8 as heartbeat_age_seconds,
|
|
65
|
+
s.restart_count,
|
|
66
|
+
case when s.last_error_occurred_at is not null
|
|
67
|
+
then extract(epoch from ((now() at time zone 'utc') - s.last_error_occurred_at))::float8
|
|
68
|
+
end as last_error_age_seconds
|
|
69
|
+
SQL
|
|
70
|
+
with_safe_conn do |conn|
|
|
71
|
+
conn.exec_params(*builder.to_exec_params)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# How far behind each subscription is.
|
|
8
|
+
#
|
|
9
|
+
# A subscription checkpoint is measured in the same units as the subscription positions frontier, not in
|
|
10
|
+
# events.global_position units - the global position sequence contains gaps and runs ahead of the frontier,
|
|
11
|
+
# so comparing a checkpoint against it over-reports by orders of magnitude. Lag is therefore always measured
|
|
12
|
+
# against the frontier.
|
|
13
|
+
#
|
|
14
|
+
# - lag_events: how much of the store the subscription still has to walk through before it reaches the
|
|
15
|
+
# frontier - that is, before it starts processing newly appended events.
|
|
16
|
+
# - lag_seconds: age of the oldest event the subscription has not processed yet; 0 when caught up.
|
|
17
|
+
#
|
|
18
|
+
# The frontier only advances while at least one subscriptions process is running. When they are all down lag
|
|
19
|
+
# stops growing - that situation is reported by the heartbeat metric of the health collector, not here.
|
|
20
|
+
class SubscriptionsLatency < Base
|
|
21
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
22
|
+
def call
|
|
23
|
+
lag_events = MetricFamily.new(
|
|
24
|
+
name: 'pg_eventstore_subscription_lag_events',
|
|
25
|
+
type: 'gauge',
|
|
26
|
+
help: 'How many events the subscription still has to catch up on before it reaches the edge of the ' \
|
|
27
|
+
'"all" stream.'
|
|
28
|
+
)
|
|
29
|
+
lag_seconds = MetricFamily.new(
|
|
30
|
+
name: 'pg_eventstore_subscription_lag_seconds',
|
|
31
|
+
type: 'gauge',
|
|
32
|
+
help: 'Age in seconds of the oldest event the subscription has not processed yet. 0 when caught up.'
|
|
33
|
+
)
|
|
34
|
+
frontier_position, head_global_position = *positions
|
|
35
|
+
rows = subscription_rows(frontier_position)
|
|
36
|
+
created_at_by_position = resolve_created_at(rows)
|
|
37
|
+
now = Time.now.utc
|
|
38
|
+
rows.each { add_samples(_1, created_at_by_position, now, lag_events, lag_seconds) }
|
|
39
|
+
[lag_events, lag_seconds, *store_families(frontier_position, head_global_position)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @param row [Hash]
|
|
45
|
+
# @param created_at_by_position [Hash<Integer => Time>]
|
|
46
|
+
# @param now [Time]
|
|
47
|
+
# @param lag_events [PgEventstore::Web::Metrics::MetricFamily]
|
|
48
|
+
# @param lag_seconds [PgEventstore::Web::Metrics::MetricFamily]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def add_samples(row, created_at_by_position, now, lag_events, lag_seconds)
|
|
51
|
+
labels = subscription_labels(row)
|
|
52
|
+
lag_events.add_sample(labels:, value: row['lag_events'])
|
|
53
|
+
position = row['event_global_position']
|
|
54
|
+
# Caught up - nothing is left to process, so there is no unprocessed event to age. Deliberately a float:
|
|
55
|
+
# the metric is seconds everywhere else, and a gauge that renders as "0" here and "12.5" there is a wart.
|
|
56
|
+
return lag_seconds.add_sample(labels:, value: 0.0) if position.nil?
|
|
57
|
+
|
|
58
|
+
created_at = created_at_by_position[position]
|
|
59
|
+
# An unprocessed position whose event no longer exists (the event or its stream was deleted). Reporting 0
|
|
60
|
+
# would read as "caught up", the opposite of the truth, so report nothing - lag_events still carries the
|
|
61
|
+
# backlog.
|
|
62
|
+
return if created_at.nil?
|
|
63
|
+
|
|
64
|
+
lag_seconds.add_sample(labels:, value: [(now - created_at).to_f, 0].max)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One index range scan per subscription over idx_event_subscription_positions_sposition_n_gposition. The
|
|
68
|
+
# cost does not grow with the size of the backlog: the oldest unprocessed position is taken with
|
|
69
|
+
# "order by subscription_position limit 1" rather than by aggregating over the whole backlog.
|
|
70
|
+
# @param frontier_position [Integer]
|
|
71
|
+
# @return [Array<Hash>]
|
|
72
|
+
def subscription_rows(frontier_position)
|
|
73
|
+
builder = subscriptions_sql_builder
|
|
74
|
+
builder.select(<<~SQL)
|
|
75
|
+
s.set,
|
|
76
|
+
s.name,
|
|
77
|
+
greatest(#{frontier_position} - coalesce(s.current_position, 0), 0) as lag_events,
|
|
78
|
+
next_event.global_position as event_global_position,
|
|
79
|
+
next_event.event_type_partition_id as event_type_partition_id
|
|
80
|
+
SQL
|
|
81
|
+
builder.join(<<~SQL)
|
|
82
|
+
left join lateral (
|
|
83
|
+
select egi.global_position, egi.event_type_partition_id
|
|
84
|
+
from event_subscription_positions esp
|
|
85
|
+
join events_global_index egi on egi.global_position = esp.global_position
|
|
86
|
+
where esp.subscription_position > coalesce(s.current_position, 0)
|
|
87
|
+
order by esp.subscription_position
|
|
88
|
+
limit 1
|
|
89
|
+
) next_event on true
|
|
90
|
+
SQL
|
|
91
|
+
with_safe_conn do |conn|
|
|
92
|
+
conn.exec_params(*builder.to_exec_params)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Resolves the creation time of every subscription's oldest unprocessed event.
|
|
97
|
+
#
|
|
98
|
+
# The events table is partitioned, and these events are known only by global position - which is not the
|
|
99
|
+
# partition key. Looking them up in events by global position alone would have to visit every partition and
|
|
100
|
+
# lock all of them, which stops being viable long before a store reaches five figures of partitions.
|
|
101
|
+
# events_global_index records the partition of each event, so the read API can resolve them partition-wise
|
|
102
|
+
# instead.
|
|
103
|
+
# @param subscription_rows [Array<Hash>]
|
|
104
|
+
# @return [Hash<Integer => Time>]
|
|
105
|
+
def resolve_created_at(subscription_rows)
|
|
106
|
+
indexes = subscription_rows.filter_map do |attrs|
|
|
107
|
+
next if attrs['event_global_position'].nil?
|
|
108
|
+
|
|
109
|
+
EventGlobalIndex::ReadApiRepr.new(
|
|
110
|
+
global_position: attrs['event_global_position'],
|
|
111
|
+
event_type_partition_id: attrs['event_type_partition_id']
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
return {} if indexes.empty?
|
|
115
|
+
|
|
116
|
+
resolved = events_global_index_queries.resolve_indexes(indexes, resolve_link_tos: false)
|
|
117
|
+
resolved.to_h { [_1['global_position'], _1['created_at']] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [PgEventstore::EventsGlobalIndexQueries]
|
|
121
|
+
def events_global_index_queries
|
|
122
|
+
EventsGlobalIndexQueries.new(connection, QueryStrategy::Foreground.new(connection))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param frontier_position [Integer]
|
|
126
|
+
# @param head_global_position [Integer]
|
|
127
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
128
|
+
def store_families(frontier_position, head_global_position)
|
|
129
|
+
frontier = MetricFamily.new(
|
|
130
|
+
name: 'pg_eventstore_store_frontier_position',
|
|
131
|
+
type: 'gauge',
|
|
132
|
+
help: 'Latest assigned subscription position. Subscription checkpoints are measured against this.'
|
|
133
|
+
)
|
|
134
|
+
frontier.add_sample(labels: {}, value: frontier_position)
|
|
135
|
+
head = MetricFamily.new(
|
|
136
|
+
name: 'pg_eventstore_store_head_global_position',
|
|
137
|
+
type: 'gauge',
|
|
138
|
+
help: 'Global position of the newest event in the store. Contains gaps; do not compare subscription ' \
|
|
139
|
+
'checkpoints against it.'
|
|
140
|
+
)
|
|
141
|
+
head.add_sample(labels: {}, value: head_global_position)
|
|
142
|
+
[frontier, head]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array[Integer]]
|
|
146
|
+
def positions
|
|
147
|
+
res = with_safe_conn do |conn|
|
|
148
|
+
conn.exec(<<~SQL)
|
|
149
|
+
select
|
|
150
|
+
(select coalesce(max(subscription_position), 0) from event_subscription_positions)
|
|
151
|
+
as frontier_position,
|
|
152
|
+
(select coalesce(max(global_position), 0) from events_global_index) as head_global_position
|
|
153
|
+
SQL
|
|
154
|
+
end
|
|
155
|
+
res.first.values_at('frontier_position', 'head_global_position')
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# Processing volume and speed of each reported subscription.
|
|
8
|
+
#
|
|
9
|
+
# Two deliberately different numbers:
|
|
10
|
+
# - processed_events_total is a counter; rate() over it gives the actual current throughput and correctly
|
|
11
|
+
# drops to 0 when no matching events arrive.
|
|
12
|
+
# - capacity_events_per_second derives from the average handler execution time of the last
|
|
13
|
+
# {PgEventstore::SubscriptionHandlerPerformance::TIMINGS_TO_KEEP} processed events - whenever they
|
|
14
|
+
# happened. It answers "how fast can this handler go when fed", is sticky while the subscription is idle
|
|
15
|
+
# and must not be read as current throughput.
|
|
16
|
+
class SubscriptionsThroughput < Base
|
|
17
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
18
|
+
def call
|
|
19
|
+
processed = MetricFamily.new(
|
|
20
|
+
name: 'pg_eventstore_subscription_processed_events_total',
|
|
21
|
+
type: 'counter',
|
|
22
|
+
help: 'Total number of events processed by the subscription. Use rate() for current throughput.'
|
|
23
|
+
)
|
|
24
|
+
capacity = MetricFamily.new(
|
|
25
|
+
name: 'pg_eventstore_subscription_capacity_events_per_second',
|
|
26
|
+
type: 'gauge',
|
|
27
|
+
help: 'Average processing speed over the last few processed events, whenever they happened. ' \
|
|
28
|
+
'Sticky while idle - this is handler capacity, not current throughput.'
|
|
29
|
+
)
|
|
30
|
+
subscription_rows.each do |row|
|
|
31
|
+
labels = subscription_labels(row)
|
|
32
|
+
processed.add_sample(labels:, value: row['total_processed_events'])
|
|
33
|
+
capacity.add_sample(labels:, value: row['capacity_eps']) if row['capacity_eps']
|
|
34
|
+
end
|
|
35
|
+
[processed, capacity]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @return [Array<Hash>]
|
|
41
|
+
def subscription_rows
|
|
42
|
+
builder = subscriptions_sql_builder
|
|
43
|
+
builder.select(<<~SQL)
|
|
44
|
+
s.set,
|
|
45
|
+
s.name,
|
|
46
|
+
s.total_processed_events,
|
|
47
|
+
case when s.average_event_processing_time > 0
|
|
48
|
+
then (1.0 / s.average_event_processing_time)::float8
|
|
49
|
+
end as capacity_eps
|
|
50
|
+
SQL
|
|
51
|
+
with_safe_conn do |conn|
|
|
52
|
+
conn.exec_params(*builder.to_exec_params)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# Renders metric families into the Prometheus text exposition format (version 0.0.4).
|
|
7
|
+
class Formatter
|
|
8
|
+
# @return [String]
|
|
9
|
+
CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
|
|
10
|
+
|
|
11
|
+
# @param families [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
12
|
+
# @return [String]
|
|
13
|
+
def call(families)
|
|
14
|
+
"#{families.map { |family| format_family(family) }.join("\n")}\n"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# @param family [PgEventstore::Web::Metrics::MetricFamily]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def format_family(family)
|
|
22
|
+
lines = ["# HELP #{family.name} #{family.help}", "# TYPE #{family.name} #{family.type}"]
|
|
23
|
+
family.samples.each do |sample|
|
|
24
|
+
lines.push("#{family.name}#{format_labels(sample[:labels])} #{sample[:value]}")
|
|
25
|
+
end
|
|
26
|
+
lines.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param labels [Hash<Symbol => String>]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def format_labels(labels)
|
|
32
|
+
return '' if labels.empty?
|
|
33
|
+
|
|
34
|
+
"{#{labels.map { |name, value| %(#{name}="#{escape_label_value(value.to_s)}") }.join(',')}}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param value [String]
|
|
38
|
+
# @return [String]
|
|
39
|
+
def escape_label_value(value)
|
|
40
|
+
value.gsub('\\', '\\\\\\\\').gsub("\n", '\\n').gsub('"', '\\"')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# Route helpers of {Metrics::Application}.
|
|
7
|
+
module Helpers
|
|
8
|
+
# @param collector_classes [Array<Class<PgEventstore::Web::Metrics::Collectors::Base>>]
|
|
9
|
+
# @return [String]
|
|
10
|
+
def metrics_response(collector_classes)
|
|
11
|
+
connection = metrics_connection
|
|
12
|
+
families = collector_classes.flat_map { _1.new(connection, sets: requested_sets).call }
|
|
13
|
+
content_type(Formatter::CONTENT_TYPE)
|
|
14
|
+
Formatter.new.call(families)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Subscription sets a scrape asks for. Accepts a repeated param ("?set=A&set=B" - what Prometheus emits for
|
|
18
|
+
# `params: {set: [A, B]}`) or a comma separated list ("?set=A,B"). Empty means every set.
|
|
19
|
+
#
|
|
20
|
+
# The query string is parsed directly because Sinatra's `params` keeps only the last value of a repeated key
|
|
21
|
+
# unless it is written as "set[]", which Prometheus does not do.
|
|
22
|
+
# @return [Array<String>]
|
|
23
|
+
def requested_sets
|
|
24
|
+
query = Rack::Utils.parse_query(request.query_string)
|
|
25
|
+
Array(query['set'] || query['set[]']).flat_map { _1.to_s.split(',') }.map(&:strip).reject(&:empty?)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# A collection of samples of a single Prometheus metric.
|
|
7
|
+
class MetricFamily
|
|
8
|
+
# @!attribute name
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_reader :name
|
|
11
|
+
# @!attribute type
|
|
12
|
+
# @return [String] "gauge" or "counter"
|
|
13
|
+
attr_reader :type
|
|
14
|
+
# @!attribute help
|
|
15
|
+
# @return [String]
|
|
16
|
+
attr_reader :help
|
|
17
|
+
# @!attribute samples
|
|
18
|
+
# @return [Array<Hash>]
|
|
19
|
+
attr_reader :samples
|
|
20
|
+
|
|
21
|
+
# @param name [String]
|
|
22
|
+
# @param type [String]
|
|
23
|
+
# @param help [String]
|
|
24
|
+
def initialize(name:, type:, help:)
|
|
25
|
+
@name = name
|
|
26
|
+
@type = type
|
|
27
|
+
@help = help
|
|
28
|
+
@samples = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param labels [Hash<Symbol => String>]
|
|
32
|
+
# @param value [Integer, Float]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def add_sample(labels:, value:)
|
|
35
|
+
@samples.push({ labels:, value: })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -30,7 +30,7 @@ module PgEventstore
|
|
|
30
30
|
|
|
31
31
|
# @return [String]
|
|
32
32
|
def first_page_link
|
|
33
|
-
path = build_path(params.slice(*(params.keys - ['starting_id'])))
|
|
33
|
+
path = url(build_path(params.slice(*(params.keys - ['starting_id']))))
|
|
34
34
|
<<~HTML
|
|
35
35
|
<li class="page-item">
|
|
36
36
|
<a class="page-link" href="#{path}" tabindex="-1">First</a>
|
|
@@ -41,17 +41,19 @@ module PgEventstore
|
|
|
41
41
|
# @param per_page [String] string representation of items per page. E.g. "10", "20", etc.
|
|
42
42
|
# @return [String]
|
|
43
43
|
def per_page_url(per_page)
|
|
44
|
-
build_path(params.merge(per_page:))
|
|
44
|
+
url(build_path(params.merge(per_page:)))
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# @param order [String] "asc"/"desc"
|
|
48
48
|
# @return [String]
|
|
49
49
|
def sort_url(order)
|
|
50
|
-
build_path(params.merge(order:))
|
|
50
|
+
url(build_path(params.merge(order:)))
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# @param should_resolve [Boolean]
|
|
54
|
+
# @return [String]
|
|
53
55
|
def resolve_link_tos_url(should_resolve)
|
|
54
|
-
build_path(params.merge(resolve_link_tos: should_resolve))
|
|
56
|
+
url(build_path(params.merge(resolve_link_tos: should_resolve)))
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
# @param number [Integer] total number of events by the current filter
|
|
@@ -82,27 +84,29 @@ module PgEventstore
|
|
|
82
84
|
|
|
83
85
|
# @param stream [PgEventstore::Stream]
|
|
84
86
|
# @return [String]
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
def stream_url(stream)
|
|
88
|
+
url(
|
|
89
|
+
build_path(
|
|
90
|
+
{
|
|
91
|
+
filter: {
|
|
92
|
+
streams: [
|
|
93
|
+
{
|
|
94
|
+
context: escape_empty_string(stream.context),
|
|
95
|
+
stream_name: escape_empty_string(stream.stream_name),
|
|
96
|
+
stream_id: escape_empty_string(stream.stream_id),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
96
100
|
},
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
base_path: '/'
|
|
102
|
+
)
|
|
99
103
|
)
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
# @param marker [String]
|
|
103
107
|
# @return [String]
|
|
104
|
-
def
|
|
105
|
-
build_path({ filter: { markers: [escape_empty_string(marker)] } },
|
|
108
|
+
def event_marker_url(marker)
|
|
109
|
+
url(build_path({ filter: { markers: [escape_empty_string(marker)] } }, base_path: '/'))
|
|
106
110
|
end
|
|
107
111
|
|
|
108
112
|
# @param str [String]
|
|
@@ -120,16 +124,16 @@ module PgEventstore
|
|
|
120
124
|
def build_starting_id_link(starting_id)
|
|
121
125
|
return 'javascript: void(0);' unless starting_id
|
|
122
126
|
|
|
123
|
-
build_path(params.merge(starting_id:))
|
|
127
|
+
url(build_path(params.merge(starting_id:)))
|
|
124
128
|
end
|
|
125
129
|
|
|
126
130
|
# @param params [Hash, Array]
|
|
127
131
|
# @return [String]
|
|
128
|
-
def build_path(params,
|
|
132
|
+
def build_path(params, base_path: request.path_info)
|
|
129
133
|
encoded_params = Rack::Utils.build_nested_query(params)
|
|
130
|
-
return
|
|
134
|
+
return base_path if encoded_params.empty?
|
|
131
135
|
|
|
132
|
-
"#{
|
|
136
|
+
"#{base_path}?#{encoded_params}"
|
|
133
137
|
end
|
|
134
138
|
end
|
|
135
139
|
end
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<td><%= empty_characters_fallback(h event.stream.context) %></td>
|
|
13
13
|
<td><%= empty_characters_fallback(h event.stream.stream_name) %></td>
|
|
14
14
|
<td>
|
|
15
|
-
<a href="<%=
|
|
15
|
+
<a href="<%= stream_url(event.stream) %>"><%= empty_characters_fallback(h event.stream.stream_id) %></a>
|
|
16
16
|
<a role="button" href="#" data-title="Copy stream definition." class="copy-to-clipboard"
|
|
17
17
|
data-clipboard-content="<%= h "PgEventstore::Stream.new(context: #{event.stream.context.inspect}, stream_name: #{event.stream.stream_name.inspect}, stream_id: #{event.stream.stream_id.inspect})" %>">
|
|
18
18
|
<i class="fa fa-clipboard"></i>
|
|
@@ -33,9 +33,11 @@
|
|
|
33
33
|
<a href="javascript: void(0);" class="d-inline-block text-nowrap toggle-event-data">
|
|
34
34
|
JSON <i class="fa fa-eye"></i>
|
|
35
35
|
</a>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
<% if can_delete? %>
|
|
37
|
+
<a href="javascript: void(0);" class="ml-2 btn btn-danger btn-small delete-event-btn" data-global-position="<%= event.global_position %>" data-url="<%= delete_event_url(event.global_position) %>" data-toggle="modal" data-target="#delete-event-modal">
|
|
38
|
+
Delete
|
|
39
|
+
</a>
|
|
40
|
+
<% end %>
|
|
39
41
|
</td>
|
|
40
42
|
</tr>
|
|
41
43
|
<tr class="event-payload d-none">
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
<% event.feature_markers.each do |feature_marker| %>
|
|
56
58
|
<li>
|
|
57
59
|
<%= "#{h feature_marker.description}:" if feature_marker.description %>
|
|
58
|
-
<a href="<%=
|
|
60
|
+
<a href="<%= event_marker_url(feature_marker.marker) %>">
|
|
59
61
|
<%= empty_characters_fallback(h feature_marker.marker) %>
|
|
60
62
|
</a>
|
|
61
63
|
</li>
|
|
@@ -66,7 +68,7 @@
|
|
|
66
68
|
Event markers
|
|
67
69
|
<ul>
|
|
68
70
|
<% event.markers.each do |marker| %>
|
|
69
|
-
<li> <a href="<%=
|
|
71
|
+
<li> <a href="<%= event_marker_url(marker) %>"><%= empty_characters_fallback(h marker) %></a> </li>
|
|
70
72
|
<% end %>
|
|
71
73
|
</ul>
|
|
72
74
|
<% end %>
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<a class="btn btn-default remove-filter" href="javascript: void(0);">
|
|
40
40
|
<i class="fa fa-minus-circle"></i>
|
|
41
41
|
</a>
|
|
42
|
-
<% if stream[:context] && stream[:stream_name] && stream[:stream_id] %>
|
|
42
|
+
<% if stream[:context] && stream[:stream_name] && stream[:stream_id] && can_delete? %>
|
|
43
43
|
<a class="btn btn-danger btn-small delete-stream" data-confirm="You are about to delete all events in <%= h stream.inspect %> stream. This action is irreversible Continue?" data-confirm-title="Delete Stream" data-method="post" href="<%= delete_stream_url(stream) %>">
|
|
44
44
|
Delete stream
|
|
45
45
|
</a>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<td><%= empty_characters_fallback(h stream.context) %></td>
|
|
5
5
|
<td><%= empty_characters_fallback(h stream.stream_name) %></td>
|
|
6
6
|
<td>
|
|
7
|
-
<a href="<%=
|
|
7
|
+
<a href="<%= stream_url(stream) %>" target="_blank"><%= empty_characters_fallback(h stream.stream_id) %></a>
|
|
8
8
|
</td>
|
|
9
9
|
<td><%= stream.stream_revision %></td>
|
|
10
10
|
</tr>
|
data/lib/pg_eventstore/web.rb
CHANGED
|
@@ -19,6 +19,14 @@ require_relative 'web/subscriptions/with_state/set_collection'
|
|
|
19
19
|
require_relative 'web/subscriptions/with_state/subscriptions'
|
|
20
20
|
require_relative 'web/subscriptions/with_state/subscriptions_set'
|
|
21
21
|
require_relative 'web/subscriptions/helpers'
|
|
22
|
+
require_relative 'web/metrics/metric_family'
|
|
23
|
+
require_relative 'web/metrics/formatter'
|
|
24
|
+
require_relative 'web/metrics/collectors/base'
|
|
25
|
+
require_relative 'web/metrics/collectors/subscriptions_latency'
|
|
26
|
+
require_relative 'web/metrics/collectors/subscriptions_health'
|
|
27
|
+
require_relative 'web/metrics/collectors/subscriptions_throughput'
|
|
28
|
+
require_relative 'web/metrics/helpers'
|
|
29
|
+
require_relative 'web/metrics/application'
|
|
22
30
|
require_relative 'web/application'
|
|
23
31
|
|
|
24
32
|
module PgEventstore
|
data/rbs_collection.lock.yaml
CHANGED
|
@@ -15,7 +15,7 @@ module PgEventstore
|
|
|
15
15
|
Integer events_number
|
|
16
16
|
) -> void
|
|
17
17
|
|
|
18
|
-
def self.update_subscription_error: (Subscription subscription,
|
|
18
|
+
def self.update_subscription_error: (Subscription subscription, StandardError error) -> void
|
|
19
19
|
|
|
20
20
|
def self.update_subscription_chunk_stats: (Subscription subscription, Integer subscription_position) -> void
|
|
21
21
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module PgEventstore
|
|
2
|
+
module RunnerRecoveryStrategies
|
|
3
|
+
class ReportSubscriptionUnrecoverableError
|
|
4
|
+
include RunnerRecoveryStrategy
|
|
5
|
+
|
|
6
|
+
@failed_subscription_notifier: _FailedSubscriptionNotifier?
|
|
7
|
+
@subscription: Subscription
|
|
8
|
+
|
|
9
|
+
def initialize: (
|
|
10
|
+
subscription: Subscription,
|
|
11
|
+
failed_subscription_notifier: _FailedSubscriptionNotifier?
|
|
12
|
+
)-> untyped
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|