rails-profiler 0.31.1 → 0.31.2
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 +59 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +53 -33
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/collectors/base_collector.rb +31 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -0
- data/lib/profiler/collectors/database_collector.rb +5 -0
- data/lib/profiler/collectors/exception_collector.rb +5 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
- data/lib/profiler/collectors/http_collector.rb +5 -0
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/request_collector.rb +14 -11
- data/lib/profiler/collectors/view_collector.rb +5 -0
- data/lib/profiler/configuration.rb +22 -3
- data/lib/profiler/console_profiler.rb +2 -17
- data/lib/profiler/job_profiler.rb +2 -15
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/middleware/capturing_body.rb +112 -0
- data/lib/profiler/middleware/profiler_middleware.rb +261 -53
- data/lib/profiler/middleware/streamed_profile.rb +135 -0
- data/lib/profiler/middleware/toolbar_injector.rb +3 -10
- data/lib/profiler/models/profile.rb +69 -5
- data/lib/profiler/redaction.rb +10 -0
- data/lib/profiler/test_profiler.rb +2 -17
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +8 -5
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../collectors/lifecycle"
|
|
4
|
+
require_relative "../current_context"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Middleware
|
|
8
|
+
# The profile of a streamed response between the moment the application returns and the
|
|
9
|
+
# moment the server closes the body. The collectors that read nothing from the request's
|
|
10
|
+
# thread (Collectors::BaseCollector#collect_from_any_thread?) stay subscribed meanwhile, so
|
|
11
|
+
# that the queries, views and logs of the stream are recorded.
|
|
12
|
+
#
|
|
13
|
+
# While the server iterates the body (enter, leave), its fiber carries the profile's token
|
|
14
|
+
# and the thread-local slots of the collectors kept, so that the logs, outbound HTTP calls
|
|
15
|
+
# and measures of the stream are recorded, and a filter by request can tell its events.
|
|
16
|
+
#
|
|
17
|
+
# Rack requires the server to close the body, but nothing here relies on it alone: a
|
|
18
|
+
# stream still open when the execution context that started it (its fiber) starts another
|
|
19
|
+
# profiled request was left behind, and its profile is finished then; one subscribed for
|
|
20
|
+
# longer than MAX_SUBSCRIBED_SECONDS has its collectors released by the next profiled
|
|
21
|
+
# request, from any thread, which the profile says. The body itself is closed only by the
|
|
22
|
+
# server. The context is the fiber, not the thread: a fiber-based server (Falcon) runs
|
|
23
|
+
# many requests at once on one thread, each in its own fiber, and Thread#[] slots are
|
|
24
|
+
# fiber-local too; a threaded server runs each request in its thread's root fiber.
|
|
25
|
+
class StreamedProfile
|
|
26
|
+
MAX_SUBSCRIBED_SECONDS = 300
|
|
27
|
+
|
|
28
|
+
@pending = {}
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def register(streamed)
|
|
33
|
+
@lock.synchronize { @pending[streamed.object_id] = streamed }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unregister(streamed)
|
|
37
|
+
@lock.synchronize { @pending.delete(streamed.object_id) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run when a profiled request starts.
|
|
41
|
+
def sweep(context = Fiber.current)
|
|
42
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
left, old = @lock.synchronize do
|
|
44
|
+
[@pending.values.select { |s| s.context.equal?(context) },
|
|
45
|
+
@pending.values.select { |s| !s.context.equal?(context) && now - s.started_at > MAX_SUBSCRIBED_SECONDS }]
|
|
46
|
+
end
|
|
47
|
+
left.each(&:abandon)
|
|
48
|
+
old.each do |streamed|
|
|
49
|
+
unregister(streamed)
|
|
50
|
+
streamed.release_collectors(after_seconds: MAX_SUBSCRIBED_SECONDS)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Releases every stream still subscribed (specs, and a process shutting down).
|
|
55
|
+
def release_all_pending
|
|
56
|
+
@lock.synchronize { @pending.values.tap { @pending.clear } }.each(&:release_collectors)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :profile, :context, :started_at
|
|
61
|
+
attr_accessor :body
|
|
62
|
+
|
|
63
|
+
# on_finish runs once, with what the body captured, when it is closed or abandoned.
|
|
64
|
+
def initialize(profile, collectors, &on_finish)
|
|
65
|
+
@profile = profile
|
|
66
|
+
@collectors = collectors
|
|
67
|
+
@on_finish = on_finish
|
|
68
|
+
@context = Fiber.current
|
|
69
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
@mutex = Mutex.new
|
|
71
|
+
@released = false
|
|
72
|
+
@finished = false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Collects the collectors kept for the stream and releases them, once, from any thread.
|
|
76
|
+
# +after_seconds+: released before the body was closed, after that long; the profile says
|
|
77
|
+
# so, as what came after is missing from it.
|
|
78
|
+
def release_collectors(after_seconds: nil)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
return if @released
|
|
81
|
+
|
|
82
|
+
@released = true
|
|
83
|
+
@profile.collectors_released_after_seconds = after_seconds if after_seconds
|
|
84
|
+
@collectors.each do |collector|
|
|
85
|
+
collector.collect if collector.respond_to?(:collect)
|
|
86
|
+
@profile.refresh_collector_metadata(collector)
|
|
87
|
+
rescue => e
|
|
88
|
+
warn "Collector #{collector.class} failed: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
Collectors::Lifecycle.release_all(@collectors)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def finish(captured, size, complete, error)
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
return if @finished
|
|
97
|
+
|
|
98
|
+
@finished = true
|
|
99
|
+
end
|
|
100
|
+
self.class.unregister(self)
|
|
101
|
+
@on_finish.call(captured, size, complete, error)
|
|
102
|
+
ensure
|
|
103
|
+
# Whatever on_finish did, nothing stays subscribed.
|
|
104
|
+
release_collectors
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The server moved on without closing the body: its profile is finished with what it
|
|
108
|
+
# captured so far. The body stays the server's to close.
|
|
109
|
+
def abandon
|
|
110
|
+
body ? body.abandon : finish("", 0, false, nil)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Called by the body around its iteration, on the fiber that iterates it. Lends nothing
|
|
114
|
+
# once the collectors are released.
|
|
115
|
+
def enter
|
|
116
|
+
token = CurrentContext.token
|
|
117
|
+
CurrentContext.token = @profile.token
|
|
118
|
+
lent = @mutex.synchronize do
|
|
119
|
+
next [] if @released
|
|
120
|
+
|
|
121
|
+
@collectors.filter_map do |collector|
|
|
122
|
+
[collector, collector.lend_thread_slots] if collector.respond_to?(:lend_thread_slots)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
[token, lent]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def leave(state)
|
|
129
|
+
token, lent = state
|
|
130
|
+
lent.reverse_each { |collector, previous| collector.return_thread_slots(previous) }
|
|
131
|
+
CurrentContext.token = token
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -129,17 +129,10 @@ module Profiler
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# The middleware hands over the page it has already read: an Array of parts, or a String.
|
|
133
|
+
# Never a body to iterate, which would drain a stream and close it.
|
|
132
134
|
def extract_content(body)
|
|
133
|
-
|
|
134
|
-
body.body
|
|
135
|
-
elsif body.respond_to?(:each)
|
|
136
|
-
parts = []
|
|
137
|
-
body.each { |part| parts << part }
|
|
138
|
-
body.close if body.respond_to?(:close)
|
|
139
|
-
parts.join
|
|
140
|
-
else
|
|
141
|
-
body.to_s
|
|
142
|
-
end
|
|
135
|
+
body.is_a?(String) ? body : Array(body).join
|
|
143
136
|
end
|
|
144
137
|
|
|
145
138
|
def toolbar_html
|
|
@@ -5,17 +5,21 @@ require "securerandom"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "zlib"
|
|
7
7
|
require_relative "../redaction"
|
|
8
|
+
require_relative "../allocation_counter"
|
|
8
9
|
|
|
9
10
|
module Profiler
|
|
10
11
|
module Models
|
|
11
12
|
class Profile
|
|
12
13
|
attr_reader :path
|
|
13
|
-
attr_accessor :token, :method, :status, :duration, :
|
|
14
|
+
attr_accessor :token, :method, :status, :duration, :allocated_objects,
|
|
14
15
|
:started_at, :finished_at, :params, :headers,
|
|
15
16
|
:response_headers, :collectors_data, :collectors_metadata,
|
|
16
17
|
:parent_token, :is_ajax, :profile_type,
|
|
17
18
|
:request_body, :request_body_encoding,
|
|
18
19
|
:response_body, :response_body_encoding,
|
|
20
|
+
:request_body_size, :request_body_truncated, :request_body_size_is_minimum,
|
|
21
|
+
:response_body_size, :response_body_truncated, :response_body_size_is_minimum,
|
|
22
|
+
:collectors_released_after_seconds,
|
|
19
23
|
:gem_version
|
|
20
24
|
|
|
21
25
|
def initialize(request = nil)
|
|
@@ -35,13 +39,32 @@ module Profiler
|
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
# The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
|
|
43
|
+
# *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
|
|
44
|
+
# stopped), only that it is at least that.
|
|
45
|
+
def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:,
|
|
46
|
+
request_body_size: nil, response_body_size: nil,
|
|
47
|
+
request_body_size_is_minimum: false, response_body_size_is_minimum: false)
|
|
39
48
|
req = process_body(request_body, req_content_type)
|
|
40
49
|
resp = process_body(response_body, resp_content_type)
|
|
41
50
|
@request_body = req[:body]
|
|
42
51
|
@request_body_encoding = req[:encoding]
|
|
43
52
|
@response_body = resp[:body]
|
|
44
53
|
@response_body_encoding = resp[:encoding]
|
|
54
|
+
@request_body_size, @request_body_truncated = body_size(request_body, request_body_size)
|
|
55
|
+
@response_body_size, @response_body_truncated = body_size(response_body, response_body_size)
|
|
56
|
+
@request_body_size_is_minimum = request_body_size_is_minimum ? true : false
|
|
57
|
+
@response_body_size_is_minimum = response_body_size_is_minimum ? true : false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Deprecated: the number of allocated objects times 40, the figure earlier versions
|
|
61
|
+
# reported as bytes. Still written in to_h, for the readers that only know it.
|
|
62
|
+
def memory
|
|
63
|
+
@allocated_objects&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def memory=(value)
|
|
67
|
+
@allocated_objects = value && value / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
def finish(status, response_headers = {})
|
|
@@ -67,8 +90,20 @@ module Profiler
|
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
def add_collector_metadata(collector)
|
|
93
|
+
@collectors_metadata << collector_metadata(collector)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A collector collected again, after the profile's tabs were listed (a streamed response
|
|
97
|
+
# that failed part way): its tab keeps its place, with what it now has.
|
|
98
|
+
def refresh_collector_metadata(collector)
|
|
99
|
+
entry = collector_metadata(collector)
|
|
100
|
+
index = @collectors_metadata.index { |tab| tab[:key] == entry[:key] }
|
|
101
|
+
index ? @collectors_metadata[index] = entry : @collectors_metadata << entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def collector_metadata(collector)
|
|
70
105
|
config = collector.tab_config
|
|
71
|
-
|
|
106
|
+
{
|
|
72
107
|
key: config[:key],
|
|
73
108
|
label: config[:label],
|
|
74
109
|
icon: config[:icon],
|
|
@@ -79,6 +114,7 @@ module Profiler
|
|
|
79
114
|
has_data: collector.has_data?
|
|
80
115
|
}
|
|
81
116
|
end
|
|
117
|
+
private :collector_metadata
|
|
82
118
|
|
|
83
119
|
def to_h
|
|
84
120
|
req_body, req_enc = decode_body(@request_body, @request_body_encoding)
|
|
@@ -92,7 +128,8 @@ module Profiler
|
|
|
92
128
|
method: @method,
|
|
93
129
|
status: @status,
|
|
94
130
|
duration: @duration,
|
|
95
|
-
|
|
131
|
+
allocated_objects: @allocated_objects,
|
|
132
|
+
memory: memory,
|
|
96
133
|
started_at: @started_at&.iso8601,
|
|
97
134
|
finished_at: @finished_at&.iso8601,
|
|
98
135
|
params: @params,
|
|
@@ -102,6 +139,13 @@ module Profiler
|
|
|
102
139
|
request_body_encoding: req_enc,
|
|
103
140
|
response_body: resp_body,
|
|
104
141
|
response_body_encoding: resp_enc,
|
|
142
|
+
request_body_size: @request_body_size,
|
|
143
|
+
request_body_truncated: @request_body_truncated,
|
|
144
|
+
request_body_size_is_minimum: @request_body_size_is_minimum,
|
|
145
|
+
response_body_size: @response_body_size,
|
|
146
|
+
response_body_truncated: @response_body_truncated,
|
|
147
|
+
response_body_size_is_minimum: @response_body_size_is_minimum,
|
|
148
|
+
collectors_released_after_seconds: @collectors_released_after_seconds,
|
|
105
149
|
collectors_data: @collectors_data,
|
|
106
150
|
tabs: @collectors_metadata,
|
|
107
151
|
parent_token: @parent_token,
|
|
@@ -125,7 +169,12 @@ module Profiler
|
|
|
125
169
|
profile.method = data[:method]
|
|
126
170
|
profile.status = data[:status]
|
|
127
171
|
profile.duration = data[:duration]
|
|
128
|
-
profile
|
|
172
|
+
# A profile saved before allocated_objects only carries memory, the count times 40.
|
|
173
|
+
if data.key?(:allocated_objects)
|
|
174
|
+
profile.allocated_objects = data[:allocated_objects]
|
|
175
|
+
else
|
|
176
|
+
profile.memory = data[:memory]
|
|
177
|
+
end
|
|
129
178
|
profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
|
|
130
179
|
profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
|
|
131
180
|
profile.params = data[:params]
|
|
@@ -135,6 +184,15 @@ module Profiler
|
|
|
135
184
|
profile.request_body_encoding = data[:request_body_encoding] || "text"
|
|
136
185
|
profile.response_body = data[:response_body]
|
|
137
186
|
profile.response_body_encoding = data[:response_body_encoding] || "text"
|
|
187
|
+
profile.request_body_size = data[:request_body_size]
|
|
188
|
+
# Left nil when the store did not keep them (SqliteStore rebuilds a profile from its
|
|
189
|
+
# columns): the request collector's data still has them.
|
|
190
|
+
profile.request_body_truncated = data[:request_body_truncated]
|
|
191
|
+
profile.request_body_size_is_minimum = data[:request_body_size_is_minimum]
|
|
192
|
+
profile.response_body_size = data[:response_body_size]
|
|
193
|
+
profile.response_body_truncated = data[:response_body_truncated]
|
|
194
|
+
profile.response_body_size_is_minimum = data[:response_body_size_is_minimum]
|
|
195
|
+
profile.collectors_released_after_seconds = data[:collectors_released_after_seconds]
|
|
138
196
|
profile.parent_token = data[:parent_token]
|
|
139
197
|
profile.is_ajax = data[:is_ajax] || false
|
|
140
198
|
profile.profile_type = data[:profile_type] || "http"
|
|
@@ -181,6 +239,12 @@ module Profiler
|
|
|
181
239
|
end
|
|
182
240
|
end
|
|
183
241
|
|
|
242
|
+
def body_size(captured, total)
|
|
243
|
+
captured_size = captured.to_s.bytesize
|
|
244
|
+
total ||= captured_size
|
|
245
|
+
[total, total > captured_size]
|
|
246
|
+
end
|
|
247
|
+
|
|
184
248
|
def compress_body?(text)
|
|
185
249
|
Profiler.configuration.compress_bodies &&
|
|
186
250
|
text.bytesize > Profiler.configuration.compress_body_threshold
|
data/lib/profiler/redaction.rb
CHANGED
|
@@ -88,6 +88,16 @@ module Profiler
|
|
|
88
88
|
text.length > max ? "#{text[0, max]}#{ellipsis}" : text
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# The first +max+ bytes of +text+, a body kept only in part. A credential cut in two by the
|
|
92
|
+
# limit would escape the masking by value, which runs later on what is kept: the bytes
|
|
93
|
+
# that could start one are left out as well. Text no longer than +max+ is returned whole.
|
|
94
|
+
def cut_bytes(text, max)
|
|
95
|
+
return text if text.bytesize <= max
|
|
96
|
+
|
|
97
|
+
cut = text.byteslice(0, max)
|
|
98
|
+
cut.byteslice(0, cut.bytesize - held_back_bytes(cut))
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
# How many trailing bytes of +text+, a stream read in pieces, could be
|
|
92
102
|
# the start of a credential and have to wait for the next piece: the
|
|
93
103
|
# longest end of the text that is also the beginning of a credential,
|
|
@@ -56,7 +56,7 @@ module Profiler
|
|
|
56
56
|
|
|
57
57
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
60
60
|
|
|
61
61
|
test_status = "passed"
|
|
62
62
|
error_message = nil
|
|
@@ -103,7 +103,7 @@ module Profiler
|
|
|
103
103
|
Profiler::CurrentContext.token = previous_token
|
|
104
104
|
|
|
105
105
|
if Profiler.configuration.track_memory
|
|
106
|
-
profile.
|
|
106
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
test_collector.update_status(test_status, error_message)
|
|
@@ -124,20 +124,5 @@ module Profiler
|
|
|
124
124
|
# After collect, and also when collect or the storage failed.
|
|
125
125
|
Collectors::Lifecycle.release_all(collectors)
|
|
126
126
|
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
def current_memory
|
|
131
|
-
return 0 unless defined?(GC.stat)
|
|
132
|
-
|
|
133
|
-
stats = GC.stat
|
|
134
|
-
if stats.key?(:total_allocated_size)
|
|
135
|
-
stats[:total_allocated_size]
|
|
136
|
-
elsif stats.key?(:total_allocated_objects)
|
|
137
|
-
stats[:total_allocated_objects] * 40
|
|
138
|
-
else
|
|
139
|
-
0
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
127
|
end
|
|
143
128
|
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.31.
|
|
4
|
+
version: 0.31.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- config/routes.rb
|
|
134
134
|
- exe/profiler-mcp
|
|
135
135
|
- lib/profiler.rb
|
|
136
|
+
- lib/profiler/allocation_counter.rb
|
|
136
137
|
- lib/profiler/boot_env.rb
|
|
137
138
|
- lib/profiler/cluster/master_client.rb
|
|
138
139
|
- lib/profiler/cluster/security.rb
|
|
@@ -206,8 +207,10 @@ files:
|
|
|
206
207
|
- lib/profiler/mcp/tools/reset_env_var.rb
|
|
207
208
|
- lib/profiler/mcp/tools/run_tests.rb
|
|
208
209
|
- lib/profiler/mcp/tools/set_env_var.rb
|
|
210
|
+
- lib/profiler/middleware/capturing_body.rb
|
|
209
211
|
- lib/profiler/middleware/cors_middleware.rb
|
|
210
212
|
- lib/profiler/middleware/profiler_middleware.rb
|
|
213
|
+
- lib/profiler/middleware/streamed_profile.rb
|
|
211
214
|
- lib/profiler/middleware/toolbar_injector.rb
|
|
212
215
|
- lib/profiler/models/profile.rb
|
|
213
216
|
- lib/profiler/models/sql_query.rb
|
|
@@ -235,13 +238,13 @@ files:
|
|
|
235
238
|
- lib/profiler/test_runner/run_store.rb
|
|
236
239
|
- lib/profiler/test_runner/runner.rb
|
|
237
240
|
- lib/profiler/version.rb
|
|
238
|
-
homepage: https://
|
|
241
|
+
homepage: https://github.com/Pepsit36/rails-profiler
|
|
239
242
|
licenses:
|
|
240
243
|
- MIT
|
|
241
244
|
metadata:
|
|
242
|
-
homepage_uri: https://
|
|
243
|
-
source_code_uri: https://
|
|
244
|
-
changelog_uri: https://
|
|
245
|
+
homepage_uri: https://github.com/Pepsit36/rails-profiler
|
|
246
|
+
source_code_uri: https://github.com/Pepsit36/rails-profiler
|
|
247
|
+
changelog_uri: https://github.com/Pepsit36/rails-profiler/blob/master/CHANGELOG.md
|
|
245
248
|
post_install_message:
|
|
246
249
|
rdoc_options: []
|
|
247
250
|
require_paths:
|