rails_observatory 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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +42 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/config/rails_observatory_manifest.js +2 -0
  5. data/app/assets/images/rails_observatory/logo.svg +8 -0
  6. data/app/assets/js/application.js +88 -0
  7. data/app/assets/js/controllers/chart_controller.js +176 -0
  8. data/app/assets/js/controllers/event_details_controller.js +15 -0
  9. data/app/assets/js/controllers/index.js +9 -0
  10. data/app/assets/js/controllers/sparkline_controller.js +72 -0
  11. data/app/assets/stylesheets/application/card.css +51 -0
  12. data/app/assets/stylesheets/application/chart.css +34 -0
  13. data/app/assets/stylesheets/application/dropdown.css +62 -0
  14. data/app/assets/stylesheets/application/global_modifiers.css +10 -0
  15. data/app/assets/stylesheets/application/query_table.css +68 -0
  16. data/app/assets/stylesheets/application/side_nav.css +62 -0
  17. data/app/assets/stylesheets/application/side_panel.css +35 -0
  18. data/app/assets/stylesheets/application/tab_nav.css +64 -0
  19. data/app/assets/stylesheets/application/table_chart.css +66 -0
  20. data/app/assets/stylesheets/application/tbd.css +70 -0
  21. data/app/assets/stylesheets/application/top_nav.css +33 -0
  22. data/app/assets/stylesheets/application.css +42 -0
  23. data/app/assets/stylesheets/elements/a.css +8 -0
  24. data/app/assets/stylesheets/elements/button.css +21 -0
  25. data/app/assets/stylesheets/elements/details.css +12 -0
  26. data/app/assets/stylesheets/elements/root.css +26 -0
  27. data/app/assets/stylesheets/elements/section.css +9 -0
  28. data/app/assets/stylesheets/errors/show/details.css +13 -0
  29. data/app/assets/stylesheets/layout/app.css +23 -0
  30. data/app/assets/stylesheets/layout/details-side-panel.css +15 -0
  31. data/app/assets/stylesheets/layout/requests.css +45 -0
  32. data/app/assets/stylesheets/layout/two-column.css +17 -0
  33. data/app/assets/stylesheets/mixins/nav_button.css +19 -0
  34. data/app/assets/stylesheets/requests/stats.css +35 -0
  35. data/app/controllers/rails_observatory/application_controller.rb +24 -0
  36. data/app/controllers/rails_observatory/errors_controller.rb +27 -0
  37. data/app/controllers/rails_observatory/jobs_controller.rb +25 -0
  38. data/app/controllers/rails_observatory/mailers_controller.rb +11 -0
  39. data/app/controllers/rails_observatory/requests_controller.rb +33 -0
  40. data/app/helpers/rails_observatory/application_helper.rb +110 -0
  41. data/app/jobs/rails_observatory/application_job.rb +4 -0
  42. data/app/mailers/rails_observatory/application_mailer.rb +6 -0
  43. data/app/views/layouts/rails_observatory/application.html.erb +93 -0
  44. data/app/views/new_user_mailer/greeting.html.erb +1 -0
  45. data/app/views/posts/index.html.erb +1 -0
  46. data/app/views/rails_observatory/application/_chart.html.erb +23 -0
  47. data/app/views/rails_observatory/application/_events_table.html.erb +24 -0
  48. data/app/views/rails_observatory/application/_sparkline.html.erb +17 -0
  49. data/app/views/rails_observatory/application/_trace.html.erb +122 -0
  50. data/app/views/rails_observatory/errors/index.html.erb +87 -0
  51. data/app/views/rails_observatory/errors/show.html.erb +193 -0
  52. data/app/views/rails_observatory/jobs/_table_chart.html.erb +29 -0
  53. data/app/views/rails_observatory/jobs/index.html.erb +20 -0
  54. data/app/views/rails_observatory/jobs/show.html.erb +8 -0
  55. data/app/views/rails_observatory/logs/index.html.erb +18 -0
  56. data/app/views/rails_observatory/mailers/index.html.erb +11 -0
  57. data/app/views/rails_observatory/mailers/show.html.erb +10 -0
  58. data/app/views/rails_observatory/requests/_text_gauge.html.erb +4 -0
  59. data/app/views/rails_observatory/requests/index.html.erb +56 -0
  60. data/app/views/rails_observatory/requests/show.html.erb +16 -0
  61. data/config/routes.rb +7 -0
  62. data/lib/rails_observatory/action_mailer_subscriber.rb +14 -0
  63. data/lib/rails_observatory/engine.rb +49 -0
  64. data/lib/rails_observatory/event_collector.rb +43 -0
  65. data/lib/rails_observatory/log_collector.rb +46 -0
  66. data/lib/rails_observatory/mailer_previews/delivered_mail_preview.rb +9 -0
  67. data/lib/rails_observatory/middleware.rb +77 -0
  68. data/lib/rails_observatory/models/error.rb +67 -0
  69. data/lib/rails_observatory/models/event_collection.rb +137 -0
  70. data/lib/rails_observatory/models/events.rb +22 -0
  71. data/lib/rails_observatory/models/job_trace.rb +28 -0
  72. data/lib/rails_observatory/models/logs.rb +9 -0
  73. data/lib/rails_observatory/models/mail_delivery.rb +33 -0
  74. data/lib/rails_observatory/models/redis_model.rb +112 -0
  75. data/lib/rails_observatory/models/request_trace.rb +29 -0
  76. data/lib/rails_observatory/railties/active_job_instrumentation.rb +48 -0
  77. data/lib/rails_observatory/railties/redis_runtime.rb +11 -0
  78. data/lib/rails_observatory/redis/logging_middleware.rb +22 -0
  79. data/lib/rails_observatory/redis/redis_client_instrumentation.rb +18 -0
  80. data/lib/rails_observatory/redis/time_series/increment_script.lua +67 -0
  81. data/lib/rails_observatory/redis/time_series/insertion.rb +73 -0
  82. data/lib/rails_observatory/redis/time_series/query_builder.rb +149 -0
  83. data/lib/rails_observatory/redis/time_series/timing_script.lua +89 -0
  84. data/lib/rails_observatory/redis/time_series.rb +91 -0
  85. data/lib/rails_observatory/serializers/event_serializer.rb +19 -0
  86. data/lib/rails_observatory/serializers/headers_serializer.rb +12 -0
  87. data/lib/rails_observatory/serializers/job_serializer.rb +11 -0
  88. data/lib/rails_observatory/serializers/mail_delivery_job_serializer.rb +14 -0
  89. data/lib/rails_observatory/serializers/request_serializer.rb +17 -0
  90. data/lib/rails_observatory/serializers/response_serializer.rb +14 -0
  91. data/lib/rails_observatory/serializers/serializer.rb +51 -0
  92. data/lib/rails_observatory/version.rb +3 -0
  93. data/lib/rails_observatory.rb +3 -0
  94. data/public/assets/js/application.js +11186 -0
  95. data/public/assets/logo_with_text.svg +21 -0
  96. data/public/assets/stylesheets/application.css +757 -0
  97. metadata +197 -0
@@ -0,0 +1,149 @@
1
+ module RailsObservatory
2
+ class TimeSeries
3
+ class QueryBuilder
4
+ include Enumerable
5
+
6
+ def initialize(series_class)
7
+ @series_class = series_class
8
+ @conditions = {}
9
+ @samples = nil
10
+ @range_set = false
11
+ @group = nil
12
+ @range = (nil..)
13
+ end
14
+
15
+ def where(**conditions)
16
+ clone = self.clone
17
+ clone.instance_variable_set(:@conditions, @conditions.merge(conditions))
18
+ clone
19
+ end
20
+
21
+ def group(label)
22
+ clone = self.clone
23
+ clone.instance_variable_set(:@group, label)
24
+ clone
25
+ end
26
+
27
+ def slice(range)
28
+ clone = self.clone
29
+ clone.instance_variable_set(:@range_set, true)
30
+ clone.instance_variable_set(:@range, range)
31
+ clone
32
+ end
33
+
34
+ def downsample(samples, using:)
35
+ clone = self.clone
36
+ clone.instance_variable_set(:@samples, samples)
37
+ clone.instance_variable_set(:@agg_type, using)
38
+ clone
39
+ end
40
+
41
+
42
+
43
+ def sum
44
+ if @group
45
+ @agg_type = :sum
46
+ @samples = 1
47
+ to_a.index_by { _1.labels[@group] }.transform_values { _1.value }
48
+ else
49
+ raise "Cannot sum without grouping"
50
+ end
51
+ end
52
+
53
+ def avg
54
+ if @group
55
+ @agg_type = :avg
56
+ @samples = 1
57
+ to_a.index_by { _1.labels[@group] }
58
+ else
59
+ raise "Cannot avg without grouping"
60
+ end
61
+ end
62
+
63
+ def last
64
+ if @group
65
+ @agg_type = :last
66
+ @samples = 1
67
+ to_a.index_by { _1.labels[@group] }
68
+ else
69
+ raise "Cannot last without grouping"
70
+ end
71
+ end
72
+
73
+ def each
74
+ @range = ActiveSupport::IsolatedExecutionState[:observatory_slice] || (nil..) unless @range_set
75
+ agg_duration = build_agg_duration
76
+ mrange_args = ['TS.MRANGE', from_ts, to_ts, 'WITHLABELS']
77
+ mrange_args.push('LATEST') if @range.end.nil?
78
+ if @agg_type && @samples
79
+ if @range.end.present?
80
+ mrange_args.push("ALIGN", 'end')
81
+ elsif @range.begin.present?
82
+ mrange_args.push("ALIGN", 'start')
83
+ end
84
+ mrange_args.push("AGGREGATION", @agg_type.to_s.upcase, agg_duration, "EMPTY")
85
+ end
86
+ mrange_args.push('FILTER', *ts_filters)
87
+
88
+ # puts mrange_args.join(" ")
89
+ res = @series_class.redis.call(mrange_args)
90
+ return if res.nil?
91
+
92
+ res.each do |name, labels, data|
93
+ yield @series_class.new(
94
+ name:,
95
+ labels: Hash[*labels.flatten],
96
+ data:,
97
+ time_range: @range,
98
+ agg_duration: agg_duration
99
+ )
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def build_agg_duration
106
+ end_time = @range.end || Time.now
107
+ start_time = @range.begin || 12.months.ago.to_time
108
+ available_datapoints = ((end_time - start_time) / 10.0).to_i
109
+ datapoints = [@samples, available_datapoints].min
110
+ ((end_time - start_time) * 1000 / datapoints).to_i
111
+ end
112
+
113
+ def from_ts
114
+ if @range.begin.nil?
115
+ "-"
116
+ else
117
+ @range.begin.to_i * 1000
118
+ end
119
+ end
120
+
121
+ def to_ts
122
+ if @range.end.nil?
123
+ "+"
124
+ else
125
+ @range.end.to_i * 1000
126
+ end
127
+ end
128
+
129
+ def ts_filters
130
+ raise 'Must specify name' if @conditions[:name].blank?
131
+
132
+ @conditions[@group] = "*" if @group
133
+ labels = @series_class.redis.call('SMEMBERS', "#{@conditions[:name]}:labels")
134
+ labels = labels.map { |l| [l.to_sym, nil] }.to_h
135
+ @conditions.reverse_merge!(labels)
136
+ @conditions.map do |k, v|
137
+ if v == "*"
138
+ "#{k}!="
139
+ elsif v.is_a? Array
140
+ "#{k}=(#{v.join(',')})"
141
+ else
142
+ "#{k}=#{v}"
143
+ end
144
+ end.to_a
145
+ end
146
+
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,89 @@
1
+ -- Helper function to get all combinations of a table
2
+ local function generate_key_combinations(keys)
3
+ local n = #keys
4
+ local combs = {}
5
+ table.insert(combs, {})
6
+
7
+ local function helper(curr_comb, start_idx)
8
+ if start_idx <= n then
9
+ for i = start_idx, n do
10
+ local new_comb = {}
11
+ for _, v in ipairs(curr_comb) do
12
+ table.insert(new_comb, v)
13
+ end
14
+ table.insert(new_comb, keys[i])
15
+ table.insert(combs, new_comb)
16
+ helper(new_comb, i + 1)
17
+ end
18
+ end
19
+ end
20
+
21
+ helper({}, 1)
22
+ return combs
23
+ end
24
+
25
+ local function extract_parent_label(base_name)
26
+ local index = string.find(base_name, "/")
27
+ if index then
28
+ return string.sub(base_name, 1, index - 1)
29
+ else
30
+ return nil
31
+ end
32
+ end
33
+
34
+ -- Main script begins here
35
+ local metric_name = tostring(ARGV[1]) -- Ensure it's a string
36
+ local value_to_add = tonumber(ARGV[2]) -- Ensure it's a number
37
+ local raw_retention = 10000 -- Hardcoded to 10ms
38
+ local compaction_retention = 31536000000 -- Hardcoded to 1 year in ms (365*24*60*60*1000)
39
+ local compactions = {"avg", "min", "max"}
40
+
41
+ -- Assuming base_name is defined somewhere above
42
+ local parent_label = extract_parent_label(metric_name)
43
+
44
+ -- Extracting labels
45
+ local labels = {}
46
+ local keys = {}
47
+ for i=3, #ARGV, 2 do
48
+ local key = tostring(ARGV[i])
49
+ local value = tostring(ARGV[i+1])
50
+ labels[key] = value
51
+ redis.call("SADD", metric_name .. ':labels', key)
52
+ table.insert(keys, key)
53
+ end
54
+
55
+ local key_combinations = generate_key_combinations(keys)
56
+
57
+ -- For each combination, upsert and add labels
58
+ for _, comb_keys in ipairs(key_combinations) do
59
+ local ts_name = metric_name
60
+ local label_set = {}
61
+
62
+ if parent_label then
63
+ table.insert(label_set, "parent")
64
+ table.insert(label_set, parent_label)
65
+ end
66
+
67
+ for _, key in ipairs(comb_keys) do
68
+ ts_name = ts_name .. ":" .. labels[key]
69
+ table.insert(label_set, key)
70
+ table.insert(label_set, labels[key])
71
+ end
72
+
73
+ if redis.call("EXISTS", ts_name) == 0 then
74
+ redis.call("TS.CREATE", ts_name, "RETENTION", raw_retention, "CHUNK_SIZE", 48)
75
+ end
76
+
77
+ -- Handle the compactions (avg, min, max)
78
+ for _, compaction in ipairs(compactions) do
79
+ local compaction_key = ts_name .. "_" .. compaction
80
+ if redis.call("EXISTS", compaction_key) == 0 then
81
+ redis.call("TS.CREATE", compaction_key, "RETENTION", compaction_retention, "CHUNK_SIZE", 48, "LABELS","name", metric_name, "compaction", compaction, unpack(label_set))
82
+ redis.call("TS.CREATERULE", ts_name, compaction_key, "AGGREGATION", compaction, 10000)
83
+ return redis.call("TS.INFO", compaction_key)
84
+ end
85
+ end
86
+ redis.call("TS.ADD", ts_name, "*", value_to_add)
87
+ end
88
+
89
+ return "OK"
@@ -0,0 +1,91 @@
1
+ require_relative 'time_series/insertion'
2
+ require_relative 'time_series/query_builder'
3
+
4
+ module RailsObservatory
5
+ class TimeSeries
6
+
7
+ extend Insertion
8
+
9
+ attr_reader :labels, :name, :data
10
+
11
+ def self.redis
12
+ Rails.configuration.rails_observatory.redis
13
+ end
14
+
15
+ def redis
16
+ self.class.redis
17
+ end
18
+
19
+ def self.with_slice(time_range)
20
+ ActiveSupport::IsolatedExecutionState[:observatory_slice] = time_range
21
+ yield
22
+ ensure
23
+ ActiveSupport::IsolatedExecutionState[:observatory_slice] = nil
24
+ end
25
+
26
+ def self.where(**conditions)
27
+ QueryBuilder.new(self).where(**conditions)
28
+ end
29
+
30
+ def initialize(name:, labels: {}, data:, time_range:, agg_duration:)
31
+ @name = name
32
+ @time_range = time_range
33
+ @agg_duration = agg_duration
34
+ @labels = labels.deep_symbolize_keys
35
+ @data = data
36
+ end
37
+
38
+ def start_time
39
+ @time_range.begin.nil? ? Time.utc(2023, 1, 1, 0, 0, 0) : @time_range.begin
40
+ end
41
+
42
+ def start_time_ms
43
+ start_time.to_i.in_milliseconds
44
+ end
45
+
46
+ def end_time_ms
47
+ end_time.to_i.in_milliseconds
48
+ end
49
+
50
+ def end_time
51
+ @time_range.end.nil? ? Time.now : @time_range.end
52
+ end
53
+
54
+ def filled_data
55
+ if @time_range.end.nil?
56
+ Enumerator
57
+ .produce(to_ms(start_time)) { |t| t + @agg_duration }
58
+ .take_while { |t| t < to_ms(end_time) }
59
+ .map do |t|
60
+ match = data.find { |ts, _| ts == t }
61
+ if match
62
+ timestamp, val = match
63
+ [timestamp, val.to_f]
64
+ else
65
+ [t, 0]
66
+ end
67
+ end
68
+ else
69
+ data.map do |ts, value|
70
+ [ts, value.to_i || 0]
71
+ end
72
+ end
73
+ end
74
+
75
+ def empty?
76
+ data.empty?
77
+ end
78
+
79
+ def value
80
+ data.dig(0, 1).to_i
81
+ end
82
+
83
+ def to_ms(duration)
84
+ self.class.to_ms(duration)
85
+ end
86
+
87
+ def self.to_ms(duration)
88
+ duration.to_i * 1_000
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ module RailsObservatory
2
+ class EventSerializer
3
+ def serialize(event)
4
+ {
5
+ name: event.name,
6
+ payload: Serializer.serialize(event.payload),
7
+ start_at: event.time,
8
+ end_at: event.end,
9
+ duration: event.duration,
10
+ allocations: event.allocations,
11
+ failed: event.payload.include?(:exception),
12
+ }
13
+ end
14
+
15
+ def self.klass
16
+ ActiveSupport::Notifications::Event
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ module RailsObservatory
2
+ class HeadersSerializer
3
+ def serialize(headers)
4
+ http_headers = Hash[*headers.select { |k, v| k.start_with?("HTTP_") }.flatten]
5
+ http_headers.transform_keys! { |k| k.sub("HTTP_", "").downcase.capitalize.dasherize }
6
+ end
7
+
8
+ def self.klass
9
+ ActionDispatch::Http::Headers
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module RailsObservatory
2
+ class JobSerializer
3
+ def serialize(job)
4
+ { class: job.class.name, job_id: job.job_id, queue_name: job.queue_name }
5
+ end
6
+
7
+ def self.klass
8
+ ActiveJob::Base
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module RailsObservatory
2
+ class MailDeliveryJobSerializer < JobSerializer
3
+ def serialize(job)
4
+ super.merge(
5
+ mailer_class: job.arguments.first,
6
+ mailer_method: job.arguments.second
7
+ )
8
+ end
9
+
10
+ def self.klass
11
+ ActionMailer::MailDeliveryJob
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module RailsObservatory
2
+ class RequestSerializer
3
+ def serialize(request)
4
+ {
5
+ method: request.method,
6
+ path: request.path,
7
+ format: request.format,
8
+ route_pattern: request.route_uri_pattern,
9
+ headers: Serializer.serialize(request.headers),
10
+ }
11
+ end
12
+
13
+ def self.klass
14
+ ActionDispatch::Request
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ module RailsObservatory
2
+ class ResponseSerializer
3
+ def serialize(response)
4
+ {
5
+ status: response.status,
6
+ # headers: Serializer.serialize(response.headers),
7
+ }
8
+ end
9
+
10
+ def self.klass
11
+ ActionDispatch::Response
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,51 @@
1
+ require_relative './job_serializer'
2
+ require_relative './mail_delivery_job_serializer'
3
+ require_relative './event_serializer'
4
+ require_relative './request_serializer'
5
+ require_relative './headers_serializer'
6
+ require_relative './response_serializer'
7
+
8
+ module RailsObservatory
9
+ class Serializer
10
+
11
+ PERMITTED_TYPES = [NilClass, String, Integer, Float, TrueClass, FalseClass]
12
+
13
+ ADDITIONAL_SERIALIZERS = [JobSerializer, MailDeliveryJobSerializer, EventSerializer, RequestSerializer, HeadersSerializer, ResponseSerializer]
14
+
15
+ class << self
16
+
17
+
18
+ def serialize(argument)
19
+ serialize_payload(argument)
20
+ end
21
+
22
+ def serialize_payload(argument)
23
+ case argument
24
+ when *PERMITTED_TYPES
25
+ argument
26
+ when Array
27
+ argument.map { serialize_payload(_1) }
28
+ when ActiveSupport::HashWithIndifferentAccess
29
+ serialize_hash(argument)
30
+ when Hash
31
+ serialize_hash(argument)
32
+ when -> (arg) { arg.respond_to?(:permitted?) && arg.respond_to?(:to_h) }
33
+ serialize_hash(argument.to_h)
34
+ when Symbol
35
+ argument.to_s
36
+ else
37
+ ADDITIONAL_SERIALIZERS.find { argument.is_a?(_1.klass) }&.new&.serialize(argument) || "Unable to serialize #{argument.class.name}"
38
+ end
39
+ end
40
+
41
+ def serialize_hash(argument)
42
+ argument.each_with_object({}) do |(key, value), hash|
43
+ case key
44
+ when String, Symbol
45
+ hash[key] = serialize_payload(value)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,3 @@
1
+ module RailsObservatory
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,3 @@
1
+ require 'rails_observatory/engine'
2
+ module RailsObservatory
3
+ end