karafka-web 0.1.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 (178) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +3 -0
  3. data/.coditsu/ci.yml +3 -0
  4. data/.diffend.yml +3 -0
  5. data/.github/FUNDING.yml +1 -0
  6. data/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  7. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  8. data/.github/workflows/ci.yml +49 -0
  9. data/.gitignore +69 -0
  10. data/.ruby-gemset +1 -0
  11. data/.ruby-version +1 -0
  12. data/CHANGELOG.md +9 -0
  13. data/CODE_OF_CONDUCT.md +46 -0
  14. data/Gemfile +7 -0
  15. data/Gemfile.lock +52 -0
  16. data/LICENSE +17 -0
  17. data/README.md +29 -0
  18. data/bin/karafka-web +33 -0
  19. data/certs/cert_chain.pem +26 -0
  20. data/config/locales/errors.yml +9 -0
  21. data/karafka-web.gemspec +44 -0
  22. data/lib/karafka/web/app.rb +17 -0
  23. data/lib/karafka/web/config.rb +80 -0
  24. data/lib/karafka/web/deserializer.rb +20 -0
  25. data/lib/karafka/web/errors.rb +25 -0
  26. data/lib/karafka/web/installer.rb +124 -0
  27. data/lib/karafka/web/processing/consumer.rb +66 -0
  28. data/lib/karafka/web/processing/consumers/aggregator.rb +130 -0
  29. data/lib/karafka/web/processing/consumers/state.rb +32 -0
  30. data/lib/karafka/web/tracking/base_contract.rb +31 -0
  31. data/lib/karafka/web/tracking/consumers/contracts/consumer_group.rb +33 -0
  32. data/lib/karafka/web/tracking/consumers/contracts/job.rb +26 -0
  33. data/lib/karafka/web/tracking/consumers/contracts/partition.rb +22 -0
  34. data/lib/karafka/web/tracking/consumers/contracts/report.rb +95 -0
  35. data/lib/karafka/web/tracking/consumers/contracts/topic.rb +29 -0
  36. data/lib/karafka/web/tracking/consumers/listeners/base.rb +33 -0
  37. data/lib/karafka/web/tracking/consumers/listeners/errors.rb +107 -0
  38. data/lib/karafka/web/tracking/consumers/listeners/pausing.rb +45 -0
  39. data/lib/karafka/web/tracking/consumers/listeners/processing.rb +157 -0
  40. data/lib/karafka/web/tracking/consumers/listeners/statistics.rb +123 -0
  41. data/lib/karafka/web/tracking/consumers/listeners/status.rb +58 -0
  42. data/lib/karafka/web/tracking/consumers/sampler.rb +216 -0
  43. data/lib/karafka/web/tracking/memoized_shell.rb +48 -0
  44. data/lib/karafka/web/tracking/reporter.rb +144 -0
  45. data/lib/karafka/web/tracking/ttl_array.rb +59 -0
  46. data/lib/karafka/web/tracking/ttl_hash.rb +16 -0
  47. data/lib/karafka/web/ui/app.rb +78 -0
  48. data/lib/karafka/web/ui/base.rb +77 -0
  49. data/lib/karafka/web/ui/controllers/base.rb +40 -0
  50. data/lib/karafka/web/ui/controllers/become_pro.rb +17 -0
  51. data/lib/karafka/web/ui/controllers/cluster.rb +24 -0
  52. data/lib/karafka/web/ui/controllers/consumers.rb +27 -0
  53. data/lib/karafka/web/ui/controllers/errors.rb +43 -0
  54. data/lib/karafka/web/ui/controllers/jobs.rb +33 -0
  55. data/lib/karafka/web/ui/controllers/requests/params.rb +30 -0
  56. data/lib/karafka/web/ui/controllers/responses/data.rb +26 -0
  57. data/lib/karafka/web/ui/controllers/routing.rb +30 -0
  58. data/lib/karafka/web/ui/helpers/application_helper.rb +144 -0
  59. data/lib/karafka/web/ui/lib/hash_proxy.rb +66 -0
  60. data/lib/karafka/web/ui/lib/paginate_array.rb +38 -0
  61. data/lib/karafka/web/ui/models/consumer_group.rb +20 -0
  62. data/lib/karafka/web/ui/models/health.rb +44 -0
  63. data/lib/karafka/web/ui/models/job.rb +13 -0
  64. data/lib/karafka/web/ui/models/message.rb +99 -0
  65. data/lib/karafka/web/ui/models/partition.rb +13 -0
  66. data/lib/karafka/web/ui/models/process.rb +56 -0
  67. data/lib/karafka/web/ui/models/processes.rb +86 -0
  68. data/lib/karafka/web/ui/models/state.rb +67 -0
  69. data/lib/karafka/web/ui/models/topic.rb +19 -0
  70. data/lib/karafka/web/ui/pro/app.rb +120 -0
  71. data/lib/karafka/web/ui/pro/controllers/cluster.rb +16 -0
  72. data/lib/karafka/web/ui/pro/controllers/consumers.rb +54 -0
  73. data/lib/karafka/web/ui/pro/controllers/dlq.rb +44 -0
  74. data/lib/karafka/web/ui/pro/controllers/errors.rb +57 -0
  75. data/lib/karafka/web/ui/pro/controllers/explorer.rb +79 -0
  76. data/lib/karafka/web/ui/pro/controllers/health.rb +33 -0
  77. data/lib/karafka/web/ui/pro/controllers/jobs.rb +26 -0
  78. data/lib/karafka/web/ui/pro/controllers/routing.rb +26 -0
  79. data/lib/karafka/web/ui/pro/views/consumers/_breadcrumbs.erb +27 -0
  80. data/lib/karafka/web/ui/pro/views/consumers/_consumer.erb +60 -0
  81. data/lib/karafka/web/ui/pro/views/consumers/_counters.erb +50 -0
  82. data/lib/karafka/web/ui/pro/views/consumers/_summary.erb +81 -0
  83. data/lib/karafka/web/ui/pro/views/consumers/consumer/_consumer_group.erb +109 -0
  84. data/lib/karafka/web/ui/pro/views/consumers/consumer/_job.erb +26 -0
  85. data/lib/karafka/web/ui/pro/views/consumers/consumer/_metrics.erb +126 -0
  86. data/lib/karafka/web/ui/pro/views/consumers/consumer/_no_jobs.erb +9 -0
  87. data/lib/karafka/web/ui/pro/views/consumers/consumer/_no_subscriptions.erb +9 -0
  88. data/lib/karafka/web/ui/pro/views/consumers/consumer/_partition.erb +32 -0
  89. data/lib/karafka/web/ui/pro/views/consumers/consumer/_stopped.erb +10 -0
  90. data/lib/karafka/web/ui/pro/views/consumers/consumer/_tabs.erb +20 -0
  91. data/lib/karafka/web/ui/pro/views/consumers/index.erb +30 -0
  92. data/lib/karafka/web/ui/pro/views/consumers/jobs.erb +42 -0
  93. data/lib/karafka/web/ui/pro/views/consumers/subscriptions.erb +23 -0
  94. data/lib/karafka/web/ui/pro/views/dlq/_breadcrumbs.erb +5 -0
  95. data/lib/karafka/web/ui/pro/views/dlq/_no_topics.erb +9 -0
  96. data/lib/karafka/web/ui/pro/views/dlq/_topic.erb +12 -0
  97. data/lib/karafka/web/ui/pro/views/dlq/index.erb +16 -0
  98. data/lib/karafka/web/ui/pro/views/errors/_breadcrumbs.erb +25 -0
  99. data/lib/karafka/web/ui/pro/views/errors/_detail.erb +29 -0
  100. data/lib/karafka/web/ui/pro/views/errors/_error.erb +26 -0
  101. data/lib/karafka/web/ui/pro/views/errors/_partition_option.erb +7 -0
  102. data/lib/karafka/web/ui/pro/views/errors/index.erb +58 -0
  103. data/lib/karafka/web/ui/pro/views/errors/show.erb +56 -0
  104. data/lib/karafka/web/ui/pro/views/explorer/_breadcrumbs.erb +29 -0
  105. data/lib/karafka/web/ui/pro/views/explorer/_detail.erb +21 -0
  106. data/lib/karafka/web/ui/pro/views/explorer/_encryption_enabled.erb +18 -0
  107. data/lib/karafka/web/ui/pro/views/explorer/_failed_deserialization.erb +4 -0
  108. data/lib/karafka/web/ui/pro/views/explorer/_message.erb +16 -0
  109. data/lib/karafka/web/ui/pro/views/explorer/_partition_option.erb +7 -0
  110. data/lib/karafka/web/ui/pro/views/explorer/_topic.erb +12 -0
  111. data/lib/karafka/web/ui/pro/views/explorer/index.erb +17 -0
  112. data/lib/karafka/web/ui/pro/views/explorer/partition.erb +56 -0
  113. data/lib/karafka/web/ui/pro/views/explorer/show.erb +65 -0
  114. data/lib/karafka/web/ui/pro/views/health/_breadcrumbs.erb +5 -0
  115. data/lib/karafka/web/ui/pro/views/health/_partition.erb +35 -0
  116. data/lib/karafka/web/ui/pro/views/health/index.erb +60 -0
  117. data/lib/karafka/web/ui/pro/views/jobs/_breadcrumbs.erb +5 -0
  118. data/lib/karafka/web/ui/pro/views/jobs/_job.erb +31 -0
  119. data/lib/karafka/web/ui/pro/views/jobs/_no_jobs.erb +9 -0
  120. data/lib/karafka/web/ui/pro/views/jobs/index.erb +34 -0
  121. data/lib/karafka/web/ui/pro/views/shared/_navigation.erb +57 -0
  122. data/lib/karafka/web/ui/public/images/favicon.ico +0 -0
  123. data/lib/karafka/web/ui/public/images/logo.svg +28 -0
  124. data/lib/karafka/web/ui/public/javascripts/application.js +41 -0
  125. data/lib/karafka/web/ui/public/javascripts/bootstrap.min.js +7 -0
  126. data/lib/karafka/web/ui/public/javascripts/highlight.min.js +337 -0
  127. data/lib/karafka/web/ui/public/javascripts/live_poll.js +124 -0
  128. data/lib/karafka/web/ui/public/javascripts/timeago.min.js +1 -0
  129. data/lib/karafka/web/ui/public/stylesheets/application.css +106 -0
  130. data/lib/karafka/web/ui/public/stylesheets/bootstrap.min.css +7 -0
  131. data/lib/karafka/web/ui/public/stylesheets/bootstrap.min.css.map +1 -0
  132. data/lib/karafka/web/ui/public/stylesheets/highlight.min.css +10 -0
  133. data/lib/karafka/web/ui/views/cluster/_breadcrumbs.erb +5 -0
  134. data/lib/karafka/web/ui/views/cluster/_broker.erb +5 -0
  135. data/lib/karafka/web/ui/views/cluster/_partition.erb +22 -0
  136. data/lib/karafka/web/ui/views/cluster/index.erb +72 -0
  137. data/lib/karafka/web/ui/views/consumers/_breadcrumbs.erb +27 -0
  138. data/lib/karafka/web/ui/views/consumers/_consumer.erb +43 -0
  139. data/lib/karafka/web/ui/views/consumers/_counters.erb +44 -0
  140. data/lib/karafka/web/ui/views/consumers/_summary.erb +81 -0
  141. data/lib/karafka/web/ui/views/consumers/consumer/_consumer_group.erb +109 -0
  142. data/lib/karafka/web/ui/views/consumers/consumer/_job.erb +26 -0
  143. data/lib/karafka/web/ui/views/consumers/consumer/_metrics.erb +126 -0
  144. data/lib/karafka/web/ui/views/consumers/consumer/_no_jobs.erb +9 -0
  145. data/lib/karafka/web/ui/views/consumers/consumer/_no_subscriptions.erb +9 -0
  146. data/lib/karafka/web/ui/views/consumers/consumer/_partition.erb +32 -0
  147. data/lib/karafka/web/ui/views/consumers/consumer/_stopped.erb +10 -0
  148. data/lib/karafka/web/ui/views/consumers/consumer/_tabs.erb +20 -0
  149. data/lib/karafka/web/ui/views/consumers/index.erb +29 -0
  150. data/lib/karafka/web/ui/views/errors/_breadcrumbs.erb +19 -0
  151. data/lib/karafka/web/ui/views/errors/_detail.erb +29 -0
  152. data/lib/karafka/web/ui/views/errors/_error.erb +26 -0
  153. data/lib/karafka/web/ui/views/errors/index.erb +38 -0
  154. data/lib/karafka/web/ui/views/errors/show.erb +30 -0
  155. data/lib/karafka/web/ui/views/jobs/_breadcrumbs.erb +5 -0
  156. data/lib/karafka/web/ui/views/jobs/_job.erb +22 -0
  157. data/lib/karafka/web/ui/views/jobs/_no_jobs.erb +9 -0
  158. data/lib/karafka/web/ui/views/jobs/index.erb +31 -0
  159. data/lib/karafka/web/ui/views/layout.erb +23 -0
  160. data/lib/karafka/web/ui/views/routing/_breadcrumbs.erb +15 -0
  161. data/lib/karafka/web/ui/views/routing/_consumer_group.erb +34 -0
  162. data/lib/karafka/web/ui/views/routing/_detail.erb +25 -0
  163. data/lib/karafka/web/ui/views/routing/_topic.erb +18 -0
  164. data/lib/karafka/web/ui/views/routing/index.erb +10 -0
  165. data/lib/karafka/web/ui/views/routing/show.erb +26 -0
  166. data/lib/karafka/web/ui/views/shared/_become_pro.erb +13 -0
  167. data/lib/karafka/web/ui/views/shared/_brand.erb +3 -0
  168. data/lib/karafka/web/ui/views/shared/_content.erb +31 -0
  169. data/lib/karafka/web/ui/views/shared/_header.erb +20 -0
  170. data/lib/karafka/web/ui/views/shared/_navigation.erb +57 -0
  171. data/lib/karafka/web/ui/views/shared/_pagination.erb +21 -0
  172. data/lib/karafka/web/ui/views/shared/exceptions/not_found.erb +39 -0
  173. data/lib/karafka/web/ui/views/shared/exceptions/pro_only.erb +52 -0
  174. data/lib/karafka/web/version.rb +8 -0
  175. data/lib/karafka/web.rb +60 -0
  176. data.tar.gz.sig +0 -0
  177. metadata +328 -0
  178. metadata.gz.sig +0 -0
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Tracking
6
+ # Namespace for all the things related to tracking consumers and consuming processes
7
+ module Consumers
8
+ # Samples for fetching and storing metrics samples about the consumer process
9
+ class Sampler
10
+ include ::Karafka::Core::Helpers::Time
11
+
12
+ attr_reader :counters, :consumer_groups, :errors, :times, :pauses, :jobs
13
+
14
+ # 60 seconds window for time tracked window-based metrics
15
+ TIMES_TTL = 60
16
+
17
+ # Times ttl in ms
18
+ TIMES_TTL_MS = TIMES_TTL * 1_000
19
+
20
+ private_constant :TIMES_TTL, :TIMES_TTL_MS
21
+
22
+ def initialize
23
+ @counters = {
24
+ batches: 0,
25
+ messages: 0,
26
+ errors: 0,
27
+ retries: 0,
28
+ dead: 0
29
+ }
30
+ @times = TtlHash.new(TIMES_TTL_MS)
31
+ @consumer_groups = {}
32
+ @errors = []
33
+ @started_at = float_now
34
+ @pauses = Set.new
35
+ @jobs = {}
36
+ @shell = MemoizedShell.new
37
+ end
38
+
39
+ # We cannot report and track the same time, that is why we use mutex here. To make sure
40
+ # that samples aggregations and counting does not interact with reporter flushing.
41
+ def track
42
+ Reporter::MUTEX.synchronize do
43
+ yield(self)
44
+ end
45
+ end
46
+
47
+ # @return [Hash] report hash with all the details about consumer operations
48
+ def to_report
49
+ {
50
+ schema_version: '1.0.0',
51
+ type: 'consumer',
52
+ dispatched_at: float_now,
53
+
54
+ process: {
55
+ started_at: @started_at,
56
+ name: process_name,
57
+ status: ::Karafka::App.config.internal.status.to_s,
58
+ listeners: Karafka::Server.listeners.count,
59
+ concurrency: concurrency,
60
+ memory_usage: memory_usage,
61
+ memory_total_usage: memory_total_usage,
62
+ memory_size: memory_size,
63
+ cpu_count: cpu_count,
64
+ cpu_usage: cpu_usage
65
+ },
66
+
67
+ versions: {
68
+ ruby: ruby_version,
69
+ karafka: ::Karafka::VERSION,
70
+ waterdrop: ::WaterDrop::VERSION
71
+ },
72
+
73
+ stats: Karafka::Server.jobs_queue.statistics.merge(
74
+ utilization: utilization
75
+ ).merge(total: @counters),
76
+
77
+ consumer_groups: @consumer_groups,
78
+ jobs: jobs.values
79
+ }
80
+ end
81
+
82
+ # Clears counters and errors.
83
+ # Used after data is reported by reported to start collecting new samples
84
+ # @note We do not clear processing or pauses or other things like this because we track
85
+ # their states and not values, so they need to be tracked between flushes.
86
+ def clear
87
+ @counters.each { |k, _| @counters[k] = 0 }
88
+
89
+ @errors.clear
90
+ end
91
+
92
+ private
93
+
94
+ # @return [Numeric] % utilization of all the threads. 100% means all the threads are
95
+ # utilized all the time within the given time window. 0% means, nothing is happening
96
+ # most if not all the time.
97
+ def utilization
98
+ return 0 if times[:total].empty?
99
+
100
+ # Max times ttl
101
+ timefactor = float_now - @started_at
102
+ timefactor = timefactor > TIMES_TTL ? TIMES_TTL : timefactor
103
+
104
+ # We divide by 1_000 to convert from milliseconds
105
+ # We multiply by 100 to have it in % scale
106
+ times[:total].sum / 1_000 / concurrency / timefactor * 100
107
+ end
108
+
109
+ # @return [String] Unique process name
110
+ def process_name
111
+ @process_name ||= "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}"
112
+ end
113
+
114
+ # @return [Integer] memory used by this process in kilobytes
115
+ def memory_usage
116
+ pid = ::Process.pid
117
+
118
+ case RUBY_PLATFORM
119
+ # Reading this that way is cheaper than running a shell command
120
+ when /linux/
121
+ IO.readlines("/proc/#{pid}/status").each do |line|
122
+ next unless line.start_with?('VmRSS:')
123
+
124
+ break line.split[1].to_i
125
+ end
126
+ when /darwin|bsd/
127
+ @shell
128
+ .call("ps -o pid,rss -p #{pid}")
129
+ .lines
130
+ .last
131
+ .split
132
+ .last
133
+ .to_i
134
+ else
135
+ 0
136
+ end
137
+ end
138
+
139
+ # Total memory used in the OS
140
+ def memory_total_usage
141
+ case RUBY_PLATFORM
142
+ when /darwin|bsd|linux/
143
+ @shell
144
+ .call('ps -A -o rss=')
145
+ .split("\n")
146
+ .inject { |a, e| a.to_i + e.strip.to_i }
147
+ else
148
+ 0
149
+ end
150
+ end
151
+
152
+ # @return [Integer] total amount of memory
153
+ def memory_size
154
+ @memory_size ||= case RUBY_PLATFORM
155
+ when /linux/
156
+ @shell
157
+ .call('grep MemTotal /proc/meminfo')
158
+ .match(/(\d+)/)
159
+ .to_s
160
+ .to_i
161
+ when /darwin|bsd/
162
+ @shell
163
+ .call('sysctl -a')
164
+ .split("\n")
165
+ .find { |line| line.start_with?('hw.memsize:') }
166
+ .to_s
167
+ .split(' ')
168
+ .last
169
+ .to_i
170
+ else
171
+ 0
172
+ end
173
+ end
174
+
175
+ # @return [Array<Float>] load averages for last 1, 5 and 15 minutes
176
+ def cpu_usage
177
+ case RUBY_PLATFORM
178
+ when /darwin|bsd|linux/
179
+ @shell
180
+ .call('w | head -1')
181
+ .strip
182
+ .split(' ')
183
+ .map(&:to_f)
184
+ .last(3)
185
+ else
186
+ [-1, -1, -1]
187
+ end
188
+ end
189
+
190
+ # @return [Integer] CPU count
191
+ def cpu_count
192
+ @cpu_count ||= Etc.nprocessors
193
+ end
194
+
195
+ # @return [String] currently used ruby version with details
196
+ def ruby_version
197
+ if defined?(JRUBY_VERSION)
198
+ revision = JRUBY_REVISION.to_s
199
+ version = JRUBY_VERSION
200
+ else
201
+ revision = RUBY_REVISION.to_s
202
+ version = RUBY_ENGINE_VERSION
203
+ end
204
+
205
+ "#{RUBY_ENGINE} #{version}-#{RUBY_PATCHLEVEL} #{revision[0..5]}"
206
+ end
207
+
208
+ # @return [Integer] number of threads that process work
209
+ def concurrency
210
+ @concurrency ||= Karafka::App.config.concurrency
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ # Namespace used to encapsulate all components needed to track and report states of particular
6
+ # processes
7
+ module Tracking
8
+ # Class used to run shell command that also returns previous result in case of a failure
9
+ # This is used because children cat get signals when performing stat fetches and then
10
+ # fetch is stopped. This can cause invalid results from sub-shell commands.
11
+ #
12
+ # This will return last result as log as there was one.
13
+ class MemoizedShell
14
+ # Hpw many tries do we want to perform before giving up on the shell command
15
+ MAX_ATTEMPTS = 4
16
+
17
+ private_constant :MAX_ATTEMPTS
18
+
19
+ def initialize
20
+ @accu = {}
21
+ end
22
+
23
+ # @param cmd [String]
24
+ # @return [String, nil] sub-shell evaluation string result or nil if we were not able to
25
+ # run or re-run the call.
26
+ def call(cmd)
27
+ attempt ||= 0
28
+
29
+ while attempt < MAX_ATTEMPTS
30
+ attempt += 1
31
+
32
+ stdout_str, status = Open3.capture2(cmd)
33
+
34
+ if status.success?
35
+ @accu[cmd] = stdout_str
36
+ return stdout_str
37
+ else
38
+ return stdout_str if attempt > MAX_ATTEMPTS
39
+ return @accu[cmd] if @accu.key?(cmd)
40
+ end
41
+ end
42
+
43
+ @accu[cmd]
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Tracking
6
+ # Reports the collected data about the process and sends it, so we can use it in the UI
7
+ class Reporter
8
+ include ::Karafka::Core::Helpers::Time
9
+ include ::Karafka::Helpers::Async
10
+
11
+ # Minimum number of messages to produce to produce then in sync mode
12
+ # This acts as a small back-off not to overload the system in case we would have extremely
13
+ # big number of errors happening
14
+ PRODUCE_SYNC_THRESHOLD = 25
15
+
16
+ private_constant :PRODUCE_SYNC_THRESHOLD
17
+
18
+ # This mutex is shared between tracker and samplers so there is no case where metrics
19
+ # would be collected same time tracker reports
20
+ MUTEX = Mutex.new
21
+
22
+ def initialize
23
+ # Move back so first report is dispatched fast to indicate, that the process is alive
24
+ @tracked_at = monotonic_now - 10_000
25
+ @consumer_contract = Consumers::Contracts::Report.new
26
+ end
27
+
28
+ # Dispatches the current state from sampler to appropriate topics
29
+ #
30
+ # @param forced [Boolean] should we report bypassing the time frequency or should we report
31
+ # only in case we would not send the report for long enough time.
32
+ def report(forced: false)
33
+ MUTEX.synchronize do
34
+ # Start background thread only when needed
35
+ # This prevents us from starting it too early or for non-consumer processes where
36
+ # Karafka is being included
37
+ async_call unless @running
38
+
39
+ return unless report?(forced)
40
+
41
+ @tracked_at = monotonic_now
42
+
43
+ consumer_report = consumer_sampler.to_report
44
+
45
+ @consumer_contract.validate!(consumer_report)
46
+
47
+ # Report consumers statuses
48
+ messages = [
49
+ {
50
+ topic: ::Karafka::Web.config.topics.consumers.reports,
51
+ payload: consumer_report.to_json,
52
+ key: consumer_report[:process][:name]
53
+ }
54
+ ]
55
+
56
+ # Report errors that occurred (if any)
57
+ messages += consumer_sampler.errors.map do |error|
58
+ {
59
+ topic: Karafka::Web.config.topics.errors,
60
+ # Inject process name into the error details for easier tracing
61
+ payload: error.merge(
62
+ process_name: consumer_report[:process][:name]
63
+ ).to_json,
64
+ # Always dispatch errors from the same process to the same partition
65
+ key: consumer_report[:process][:name]
66
+ }
67
+ end
68
+
69
+ produce(messages)
70
+
71
+ # Clear the sampler so it tracks new state changes without previous once impacting
72
+ # the data
73
+ consumer_sampler.clear
74
+ end
75
+ # Since we run this in a background thread, there may be a case upon shutdown, where the
76
+ # producer is closed right before a potential dispatch. It is not worth dealing with this
77
+ # and we can just safely ignore this
78
+ rescue WaterDrop::Errors::ProducerClosedError
79
+ nil
80
+ end
81
+
82
+ # Reports bypassing frequency check. This can be used to report when state changes in the
83
+ # process drastically. For example when process is stopping, we want to indicate this as
84
+ # fast as possible in the UI, etc.
85
+ def report!
86
+ report(forced: true)
87
+ end
88
+
89
+ private
90
+
91
+ # Reports the process state once in a while
92
+ def call
93
+ @running = true
94
+
95
+ loop do
96
+ report
97
+
98
+ # We won't track more often anyhow but want to try frequently not to miss a window
99
+ # We need to convert the sleep interval into seconds for sleep
100
+ sleep(::Karafka::Web.config.tracking.interval / 1_000 / 10)
101
+ end
102
+ end
103
+
104
+ # @param forced [Boolean] is this report forced. Forced means that as long as we can flush
105
+ # we will flush
106
+ # @return [Boolean] Should we report or is it not yet time to do so
107
+ def report?(forced)
108
+ # We never report in initializing phase because things are not yet fully configured
109
+ return false if ::Karafka::App.initializing?
110
+ # We never report in the initialized because server is not yet ready until Karafka is
111
+ # fully running and some of the things like listeners are not yet available
112
+ return false if ::Karafka::App.initialized?
113
+
114
+ return true if forced
115
+
116
+ (monotonic_now - @tracked_at) >= ::Karafka::Web.config.tracking.interval
117
+ end
118
+
119
+ # @return [Object] sampler for the metrics
120
+ def consumer_sampler
121
+ @consumer_sampler ||= ::Karafka::Web.config.tracking.consumers.sampler
122
+ end
123
+
124
+ # Produces messages to Kafka.
125
+ #
126
+ # @param messages [Array<Hash>]
127
+ #
128
+ # @note We pick either sync or async dependent on number of messages. The trick here is,
129
+ # that we do not want to end up overloading the internal queue with messages in case
130
+ # someone has a lot of errors from processing or other errors. Producing sync will wait
131
+ # for the delivery, hence will slow things down a little bit. On the other hand during
132
+ # normal operations we should not have that many messages to dispatch and it should not
133
+ # slowdown any processing.
134
+ def produce(messages)
135
+ if messages.count >= PRODUCE_SYNC_THRESHOLD
136
+ ::Karafka.producer.produce_many_sync(messages)
137
+ else
138
+ ::Karafka.producer.produce_many_async(messages)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Tracking
6
+ # Array that allows us to store data points that expire over time automatically.
7
+ class TtlArray
8
+ include ::Karafka::Core::Helpers::Time
9
+ include Enumerable
10
+
11
+ # @param ttl [Integer] milliseconds ttl
12
+ def initialize(ttl)
13
+ @ttl = ttl
14
+ @accu = []
15
+ end
16
+
17
+ # Iterates over only active elements
18
+ def each
19
+ clear
20
+
21
+ @accu.each do |sample|
22
+ yield sample[:value]
23
+ end
24
+ end
25
+
26
+ # @param value [Object] adds value to the array
27
+ # @return [Object] added element
28
+ def <<(value)
29
+ @accu << { value: value, added_at: monotonic_now }
30
+
31
+ clear
32
+
33
+ value
34
+ end
35
+
36
+ # @return [Boolean] is the array empty
37
+ def empty?
38
+ clear
39
+ @accu.empty?
40
+ end
41
+
42
+ # @return [Array] pure array version with only active elements
43
+ def to_a
44
+ clear
45
+ super
46
+ end
47
+
48
+ private
49
+
50
+ # Evicts outdated samples
51
+ def clear
52
+ @accu.delete_if do |sample|
53
+ monotonic_now - sample[:added_at] > @ttl
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Tracking
6
+ # Hash that accumulates data that has an expiration date (ttl)
7
+ # Used to keep track of metrics in a window
8
+ class TtlHash < Hash
9
+ # @param ttl [Integer] milliseconds ttl
10
+ def initialize(ttl)
11
+ super() { |k, v| k[v] = TtlArray.new(ttl) }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ # Web UI namespace
6
+ module Ui
7
+ # Main Roda Web App that servers all the metrics and stats
8
+ class App < Base
9
+ # Use the gem views and assets location
10
+ opts[:root] = Karafka::Web.gem_root.join('lib/karafka/web/ui')
11
+
12
+ instance_exec(&CONTEXT_DETAILS)
13
+
14
+ route do |r|
15
+ r.root { r.redirect root_path('consumers') }
16
+
17
+ @current_page = params.current_page
18
+
19
+ r.on 'consumers' do
20
+ r.get String, 'subscriptions' do |_process_id|
21
+ raise Errors::Ui::ProOnlyError
22
+ end
23
+
24
+ r.get do
25
+ @breadcrumbs = false
26
+ controller = Controllers::Consumers.new(params)
27
+ render_response controller.index
28
+ end
29
+ end
30
+
31
+ %w[
32
+ health
33
+ explorer
34
+ dlq
35
+ ].each do |route|
36
+ r.get route, [String, true], [String, true] do
37
+ raise Errors::Ui::ProOnlyError
38
+ end
39
+ end
40
+
41
+ r.get 'jobs' do
42
+ controller = Controllers::Jobs.new(params)
43
+ render_response controller.index
44
+ end
45
+
46
+ r.on 'routing' do
47
+ controller = Controllers::Routing.new(params)
48
+
49
+ r.get String do |topic_id|
50
+ render_response controller.show(topic_id)
51
+ end
52
+
53
+ r.get do
54
+ render_response controller.index
55
+ end
56
+ end
57
+
58
+ r.get 'cluster' do
59
+ controller = Controllers::Cluster.new(params)
60
+ render_response controller.index
61
+ end
62
+
63
+ r.on 'errors' do
64
+ controller = Controllers::Errors.new(params)
65
+
66
+ r.get Integer do |offset|
67
+ render_response controller.show(offset)
68
+ end
69
+
70
+ r.get do
71
+ render_response controller.index
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Ui
6
+ # Base Roda application
7
+ class Base < Roda
8
+ include Helpers::ApplicationHelper
9
+
10
+ # Details that need to be evaluated in the context of OSS or Pro web UI.
11
+ # If those would be evaluated in the base, they would not be initialized as expected
12
+ CONTEXT_DETAILS = lambda do
13
+ plugin(
14
+ :static,
15
+ %w[/javascripts /stylesheets /images],
16
+ root: Karafka::Web.gem_root.join('lib/karafka/web/ui/public')
17
+ )
18
+ plugin :render_each
19
+ plugin :partials
20
+ end
21
+
22
+ plugin :render, escape: true, engine: 'erb'
23
+ plugin :run_append_slash
24
+ plugin :error_handler
25
+ plugin :not_found
26
+ plugin :path
27
+
28
+ # Display appropriate error specific to a given error type
29
+ plugin :error_handler, classes: [
30
+ ::Karafka::Web::Errors::Ui::NotFoundError,
31
+ ::Rdkafka::RdkafkaError,
32
+ Errors::Ui::ProOnlyError
33
+ ] do |e|
34
+ @error = true
35
+
36
+ if e.is_a?(Errors::Ui::ProOnlyError)
37
+ view 'shared/exceptions/pro_only'
38
+ else
39
+ response.status = 404
40
+ view 'shared/exceptions/not_found'
41
+ end
42
+ end
43
+
44
+ not_found do
45
+ @error = true
46
+ response.status = 404
47
+ view 'shared/exceptions/not_found'
48
+ end
49
+
50
+ # Allows us to build current path with additional params
51
+ # @param query_data [Hash] query params we want to add to the current path
52
+ path :current do |query_data = {}|
53
+ q = query_data.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
54
+ "#{request.path}?#{q}"
55
+ end
56
+
57
+ # Sets appropriate template variables based on the response object and renders the
58
+ # expected view
59
+ # @param response [Karafka::Web::Ui::Controllers::Responses::Data] response data object
60
+ def render_response(response)
61
+ response.attributes.each do |key, value|
62
+ instance_variable_set(
63
+ "@#{key}", value
64
+ )
65
+ end
66
+
67
+ view(response.path)
68
+ end
69
+
70
+ # @return [Karafka::Web::Ui::Controllers::Requests::Params] curated params
71
+ def params
72
+ Controllers::Requests::Params.new(request.params)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Ui
6
+ # Namespace for controller related components in the Web UI app.
7
+ module Controllers
8
+ # Base controller from which all the controllers should inherit.
9
+ class Base
10
+ # @param params [Karafka::Web::Ui::Controllers::Requests::Params] request parameters
11
+ def initialize(params)
12
+ @params = params
13
+ end
14
+
15
+ # Builds the respond data object with assigned attributes based on instance variables.
16
+ #
17
+ # @return [Responses::Data] data that should be used to render appropriate view
18
+ def respond
19
+ attributes = {}
20
+
21
+ scope = self.class.to_s.split('::').last.gsub(/(.)([A-Z])/, '\1_\2').downcase
22
+ action = caller_locations(1, 1)[0].label
23
+
24
+ instance_variables.each do |iv|
25
+ next if iv.to_s.start_with?('@_')
26
+ next if iv.to_s.start_with?('@params')
27
+
28
+ attributes[iv.to_s.delete('@').to_sym] = instance_variable_get(iv)
29
+ end
30
+
31
+ Responses::Data.new(
32
+ "#{scope}/#{action}",
33
+ attributes
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Ui
6
+ module Controllers
7
+ # Pro message reporting info controller
8
+ class BecomePro < Base
9
+ # Display a message, that a give feature is available only in Pro
10
+ def show
11
+ respond
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end