puma-plugin-telemetry_too 0.0.1.alpha1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +115 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/Rakefile +10 -0
- data/docs/example-datadog_backlog_size.png +0 -0
- data/docs/example-datadog_queue_time.png +0 -0
- data/docs/examples.md +163 -0
- data/lib/puma/plugin/telemetry_too/config.rb +132 -0
- data/lib/puma/plugin/telemetry_too/data.rb +287 -0
- data/lib/puma/plugin/telemetry_too/formatters/json_formatter.rb +18 -0
- data/lib/puma/plugin/telemetry_too/formatters/logfmt_formatter.rb +16 -0
- data/lib/puma/plugin/telemetry_too/formatters/passthrough_formatter.rb +16 -0
- data/lib/puma/plugin/telemetry_too/targets/base_formatting_target.rb +46 -0
- data/lib/puma/plugin/telemetry_too/targets/datadog_statsd_target.rb +51 -0
- data/lib/puma/plugin/telemetry_too/targets/io_target.rb +27 -0
- data/lib/puma/plugin/telemetry_too/targets/log_target.rb +29 -0
- data/lib/puma/plugin/telemetry_too/transforms/cloud_watch_transform.rb +23 -0
- data/lib/puma/plugin/telemetry_too/transforms/l2met_transform.rb +46 -0
- data/lib/puma/plugin/telemetry_too/transforms/passthrough_transform.rb +16 -0
- data/lib/puma/plugin/telemetry_too/version.rb +9 -0
- data/lib/puma/plugin/telemetry_too.rb +118 -0
- data/lib/rack/request_queue_time_middleware.rb +60 -0
- metadata +97 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puma
|
|
4
|
+
class Plugin
|
|
5
|
+
module TelemetryToo
|
|
6
|
+
# Configuration object for plugin
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_PUMA_TELEMETRY = [
|
|
9
|
+
# Total booted workers.
|
|
10
|
+
'workers.booted',
|
|
11
|
+
|
|
12
|
+
# Total number of workers configured.
|
|
13
|
+
'workers.total',
|
|
14
|
+
|
|
15
|
+
# Current number of threads spawned.
|
|
16
|
+
'workers.spawned_threads',
|
|
17
|
+
|
|
18
|
+
# Maximum number of threads that can run .
|
|
19
|
+
'workers.max_threads',
|
|
20
|
+
|
|
21
|
+
# Number of requests performed so far.
|
|
22
|
+
'workers.requests_count',
|
|
23
|
+
|
|
24
|
+
# Number of requests waiting to be processed.
|
|
25
|
+
'queue.backlog',
|
|
26
|
+
|
|
27
|
+
# Maximum number of requests held in Puma's Reactor which is used for
|
|
28
|
+
# asyncronously buffering request bodies. This stat is reset on every
|
|
29
|
+
# call, so it's the maximum value observed since the last call
|
|
30
|
+
'queue.backlog_max',
|
|
31
|
+
|
|
32
|
+
# Maximum number of requests that have been fully buffered by the
|
|
33
|
+
# Reactor and placed in a ready queue, but have not yet been picked
|
|
34
|
+
# up by a server thread. This stat is reset on every call, so it's
|
|
35
|
+
# the maximum value observed since the last stat call.
|
|
36
|
+
'queue.reactor_max',
|
|
37
|
+
|
|
38
|
+
# Free capacity that could be utilized, i.e. if backlog
|
|
39
|
+
# is growing, and we still have capacity available, it
|
|
40
|
+
# could mean that load balancing is not performing well.
|
|
41
|
+
'queue.capacity'
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
TARGETS = {
|
|
45
|
+
dogstatsd: TelemetryToo::Targets::DatadogStatsdTarget,
|
|
46
|
+
io: TelemetryToo::Targets::IOTarget,
|
|
47
|
+
log: TelemetryToo::Targets::LogTarget
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Whenever telemetry should run with puma
|
|
51
|
+
# - default: false
|
|
52
|
+
attr_accessor :enabled
|
|
53
|
+
|
|
54
|
+
# Number of seconds to delay first telemetry
|
|
55
|
+
# - default: 5
|
|
56
|
+
attr_accessor :initial_delay
|
|
57
|
+
|
|
58
|
+
# Seconds between publishing telemetry
|
|
59
|
+
# - default: 5
|
|
60
|
+
attr_accessor :frequency
|
|
61
|
+
|
|
62
|
+
# List of targets which are meant to publish telemetry.
|
|
63
|
+
# Target should implement `#call` method accepting
|
|
64
|
+
# a single argument - so it can be even a simple proc.
|
|
65
|
+
# - default: []
|
|
66
|
+
attr_accessor :targets
|
|
67
|
+
|
|
68
|
+
# Which metrics to publish from puma stats. You can select
|
|
69
|
+
# a subset from default ones that interest you the most.
|
|
70
|
+
# - default: DEFAULT_PUMA_TELEMETRY
|
|
71
|
+
attr_accessor :puma_telemetry
|
|
72
|
+
|
|
73
|
+
# Whenever to publish socket telemetry.
|
|
74
|
+
# - default: false
|
|
75
|
+
attr_accessor :socket_telemetry
|
|
76
|
+
|
|
77
|
+
# Symbol representing method to parse the `Socket::Option`, or
|
|
78
|
+
# the whole implementation as a lambda. Available options:
|
|
79
|
+
# - `:inspect`, based on the `Socket::Option#inspect` method,
|
|
80
|
+
# it's the safest and slowest way to extract the info. `inspect`
|
|
81
|
+
# output might not be available, i.e. on AWS Fargate
|
|
82
|
+
# - `:unpack`, parse binary data given by `Socket::Option`. Fastest
|
|
83
|
+
# way (12x compared to `inspect`) but depends on kernel headers
|
|
84
|
+
# and fields ordering within the struct. It should almost always
|
|
85
|
+
# match though. DEFAULT
|
|
86
|
+
# - proc/lambda, `Socket::Option` will be given as an argument, it
|
|
87
|
+
# should return the value of `unacked` field as an integer.
|
|
88
|
+
#
|
|
89
|
+
attr_accessor :socket_parser
|
|
90
|
+
|
|
91
|
+
def initialize
|
|
92
|
+
@enabled = false
|
|
93
|
+
@initial_delay = 5
|
|
94
|
+
@frequency = 5
|
|
95
|
+
@targets = []
|
|
96
|
+
@puma_telemetry = DEFAULT_PUMA_TELEMETRY
|
|
97
|
+
@socket_telemetry = false
|
|
98
|
+
@socket_parser = :unpack
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def enabled?
|
|
102
|
+
!!@enabled
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def socket_telemetry!
|
|
106
|
+
# These structs are platform specific, and not available on macOS,
|
|
107
|
+
# for example. If they're undefined, then we cannot capture socket
|
|
108
|
+
# telemetry. We'll warn in that case.
|
|
109
|
+
if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_INFO)
|
|
110
|
+
@socket_telemetry = true
|
|
111
|
+
else
|
|
112
|
+
warn("Cannot capture socket telemetry on this platform (#{RUBY_PLATFORM}); socket_telemetry is disabled.")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def socket_telemetry?
|
|
117
|
+
@socket_telemetry
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_target(name_or_target, **args)
|
|
121
|
+
return @targets.push(name_or_target) unless name_or_target.is_a?(Symbol)
|
|
122
|
+
|
|
123
|
+
target = TARGETS.fetch(name_or_target) do
|
|
124
|
+
raise TelemetryToo::Error, "Unknown Target: #{name_or_target.inspect}, #{args.inspect}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@targets.push(target.new(**args))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puma
|
|
4
|
+
class Plugin
|
|
5
|
+
module TelemetryToo
|
|
6
|
+
# Helper for working with Puma stats
|
|
7
|
+
module CommonData
|
|
8
|
+
TELEMETRY_TO_METHODS = {
|
|
9
|
+
'workers.booted' => :workers_booted,
|
|
10
|
+
'workers.total' => :workers_total,
|
|
11
|
+
'workers.spawned_threads' => :workers_spawned_threads,
|
|
12
|
+
'workers.max_threads' => :workers_max_threads,
|
|
13
|
+
'workers.requests_count' => :workers_requests_count,
|
|
14
|
+
'queue.backlog' => :queue_backlog,
|
|
15
|
+
'queue.backlog_max' => :queue_backlog_max,
|
|
16
|
+
'queue.reactor_max' => :queue_reactor_max,
|
|
17
|
+
'queue.capacity' => :queue_capacity
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(stats)
|
|
21
|
+
@stats = stats
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def workers_booted
|
|
25
|
+
@stats.fetch(:booted_workers, 1)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def workers_total
|
|
29
|
+
@stats.fetch(:workers, 1)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def metrics(selected)
|
|
33
|
+
selected.each_with_object({}) do |metric, obj|
|
|
34
|
+
next unless TELEMETRY_TO_METHODS.key?(metric)
|
|
35
|
+
|
|
36
|
+
obj[metric] = public_send(TELEMETRY_TO_METHODS[metric])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Handles the case of non clustered mode, where `workers` isn't configured
|
|
42
|
+
class WorkerData
|
|
43
|
+
include CommonData
|
|
44
|
+
|
|
45
|
+
def workers_max_threads
|
|
46
|
+
@stats.fetch(:max_threads, 0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def workers_requests_count
|
|
50
|
+
@stats.fetch(:requests_count, 0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def workers_spawned_threads
|
|
54
|
+
@stats.fetch(:running, 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def queue_backlog
|
|
58
|
+
@stats.fetch(:backlog, 0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def queue_capacity
|
|
62
|
+
@stats.fetch(:pool_capacity, 0)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def queue_reactor_max
|
|
66
|
+
@stats.fetch(:reactor_max, 0)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def queue_backlog_max
|
|
70
|
+
@stats.fetch(:backlog_max, 0)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Handles the case of clustered mode, where we have statistics
|
|
75
|
+
# for all the workers. This class takes care of summing all
|
|
76
|
+
# relevant data.
|
|
77
|
+
class ClusteredData
|
|
78
|
+
include CommonData
|
|
79
|
+
|
|
80
|
+
def workers_max_threads
|
|
81
|
+
sum_stat(:max_threads)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def workers_requests_count
|
|
85
|
+
sum_stat(:requests_count)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def workers_spawned_threads
|
|
89
|
+
sum_stat(:running)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def queue_backlog
|
|
93
|
+
sum_stat(:backlog)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def queue_capacity
|
|
97
|
+
sum_stat(:pool_capacity)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def queue_reactor_max
|
|
101
|
+
sum_stat(:reactor_max)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def queue_backlog_max
|
|
105
|
+
sum_stat(:backlog_max)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def sum_stat(stat)
|
|
111
|
+
@stats[:worker_status].reduce(0) do |sum, data|
|
|
112
|
+
(data.dig(:last_status, stat) || 0) + sum
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Pulls TCP INFO data from socket
|
|
118
|
+
class SocketData
|
|
119
|
+
UNACKED_REGEXP = /\ unacked=(?<unacked>\d+)\ /
|
|
120
|
+
|
|
121
|
+
def initialize(ios, parser)
|
|
122
|
+
@sockets = ios.select { |io| io.respond_to?(:getsockopt) && io.is_a?(TCPSocket) }
|
|
123
|
+
@parser =
|
|
124
|
+
case parser
|
|
125
|
+
when :inspect then method(:parse_with_inspect)
|
|
126
|
+
when :unpack then method(:parse_with_unpack)
|
|
127
|
+
when Proc then parser
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Number of unacknowledged connections in the sockets, which
|
|
132
|
+
# we know as socket backlog.
|
|
133
|
+
#
|
|
134
|
+
def unacked
|
|
135
|
+
@sockets.sum do |socket|
|
|
136
|
+
@parser.call(socket.getsockopt(Socket::SOL_TCP,
|
|
137
|
+
Socket::TCP_INFO))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def metrics
|
|
142
|
+
{
|
|
143
|
+
'sockets.backlog' => unacked
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# The Socket::Option returned by `getsockopt` doesn't provide
|
|
150
|
+
# any kind of accessors for data inside. It decodes it on demand
|
|
151
|
+
# for `inspect` as strings in C implementation. It looks like
|
|
152
|
+
#
|
|
153
|
+
# #<Socket::Option: INET TCP INFO state=LISTEN
|
|
154
|
+
# ca_state=Open
|
|
155
|
+
# retransmits=0
|
|
156
|
+
# probes=0
|
|
157
|
+
# backoff=0
|
|
158
|
+
# options=0
|
|
159
|
+
# rto=0.000000s
|
|
160
|
+
# ato=0.000000s
|
|
161
|
+
# snd_mss=0
|
|
162
|
+
# rcv_mss=0
|
|
163
|
+
# unacked=0
|
|
164
|
+
# sacked=5
|
|
165
|
+
# lost=0
|
|
166
|
+
# retrans=0
|
|
167
|
+
# fackets=0
|
|
168
|
+
# last_data_sent=0.000s
|
|
169
|
+
# last_ack_sent=0.000s
|
|
170
|
+
# last_data_recv=0.000s
|
|
171
|
+
# last_ack_recv=0.000s
|
|
172
|
+
# pmtu=0
|
|
173
|
+
# rcv_ssthresh=0
|
|
174
|
+
# rtt=0.000000s
|
|
175
|
+
# rttvar=0.000000s
|
|
176
|
+
# snd_ssthresh=0
|
|
177
|
+
# snd_cwnd=10
|
|
178
|
+
# advmss=0
|
|
179
|
+
# reordering=3
|
|
180
|
+
# rcv_rtt=0.000000s
|
|
181
|
+
# rcv_space=0
|
|
182
|
+
# total_retrans=0
|
|
183
|
+
# (128 bytes too long)>
|
|
184
|
+
#
|
|
185
|
+
# That's why pulling the `unacked` field by parsing
|
|
186
|
+
# `inspect` output is one of the ways to retrieve it.
|
|
187
|
+
#
|
|
188
|
+
def parse_with_inspect(tcp_info)
|
|
189
|
+
tcp_match = tcp_info.inspect.match(UNACKED_REGEXP)
|
|
190
|
+
|
|
191
|
+
return 0 if tcp_match.nil?
|
|
192
|
+
|
|
193
|
+
tcp_match[:unacked].to_i
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The above inspect data might not be available everywhere (looking at you
|
|
197
|
+
# AWS Fargate Host running on kernel 4.14!), but we might still recover it
|
|
198
|
+
# by manually unpacking the binary data based on linux headers. For example
|
|
199
|
+
# below is tcp info struct from `linux/tcp.h` header file, from problematic
|
|
200
|
+
# host rocking kernel 4.14.
|
|
201
|
+
#
|
|
202
|
+
# struct tcp_info {
|
|
203
|
+
# __u8 tcpi_state;
|
|
204
|
+
# __u8 tcpi_ca_state;
|
|
205
|
+
# __u8 tcpi_retransmits;
|
|
206
|
+
# __u8 tcpi_probes;
|
|
207
|
+
# __u8 tcpi_backoff;
|
|
208
|
+
# __u8 tcpi_options;
|
|
209
|
+
# __u8 tcpi_snd_wscale : 4, tcpi_rcv_wscale : 4;
|
|
210
|
+
# __u8 tcpi_delivery_rate_app_limited:1;
|
|
211
|
+
#
|
|
212
|
+
# __u32 tcpi_rto;
|
|
213
|
+
# __u32 tcpi_ato;
|
|
214
|
+
# __u32 tcpi_snd_mss;
|
|
215
|
+
# __u32 tcpi_rcv_mss;
|
|
216
|
+
#
|
|
217
|
+
# __u32 tcpi_unacked;
|
|
218
|
+
# __u32 tcpi_sacked;
|
|
219
|
+
# __u32 tcpi_lost;
|
|
220
|
+
# __u32 tcpi_retrans;
|
|
221
|
+
# __u32 tcpi_fackets;
|
|
222
|
+
#
|
|
223
|
+
# /* Times. */
|
|
224
|
+
# __u32 tcpi_last_data_sent;
|
|
225
|
+
# __u32 tcpi_last_ack_sent; /* Not remembered, sorry. */
|
|
226
|
+
# __u32 tcpi_last_data_recv;
|
|
227
|
+
# __u32 tcpi_last_ack_recv;
|
|
228
|
+
#
|
|
229
|
+
# /* Metrics. */
|
|
230
|
+
# __u32 tcpi_pmtu;
|
|
231
|
+
# __u32 tcpi_rcv_ssthresh;
|
|
232
|
+
# __u32 tcpi_rtt;
|
|
233
|
+
# __u32 tcpi_rttvar;
|
|
234
|
+
# __u32 tcpi_snd_ssthresh;
|
|
235
|
+
# __u32 tcpi_snd_cwnd;
|
|
236
|
+
# __u32 tcpi_advmss;
|
|
237
|
+
# __u32 tcpi_reordering;
|
|
238
|
+
#
|
|
239
|
+
# __u32 tcpi_rcv_rtt;
|
|
240
|
+
# __u32 tcpi_rcv_space;
|
|
241
|
+
#
|
|
242
|
+
# __u32 tcpi_total_retrans;
|
|
243
|
+
#
|
|
244
|
+
# __u64 tcpi_pacing_rate;
|
|
245
|
+
# __u64 tcpi_max_pacing_rate;
|
|
246
|
+
# __u64 tcpi_bytes_acked; /* RFC4898 tcpEStatsAppHCThruOctetsAcked */
|
|
247
|
+
# __u64 tcpi_bytes_received; /* RFC4898 tcpEStatsAppHCThruOctetsReceived */
|
|
248
|
+
# __u32 tcpi_segs_out; /* RFC4898 tcpEStatsPerfSegsOut */
|
|
249
|
+
# __u32 tcpi_segs_in; /* RFC4898 tcpEStatsPerfSegsIn */
|
|
250
|
+
#
|
|
251
|
+
# __u32 tcpi_notsent_bytes;
|
|
252
|
+
# __u32 tcpi_min_rtt;
|
|
253
|
+
# __u32 tcpi_data_segs_in; /* RFC4898 tcpEStatsDataSegsIn */
|
|
254
|
+
# __u32 tcpi_data_segs_out; /* RFC4898 tcpEStatsDataSegsOut */
|
|
255
|
+
#
|
|
256
|
+
# __u64 tcpi_delivery_rate;
|
|
257
|
+
#
|
|
258
|
+
# __u64 tcpi_busy_time; /* Time (usec) busy sending data */
|
|
259
|
+
# __u64 tcpi_rwnd_limited; /* Time (usec) limited by receive window */
|
|
260
|
+
# __u64 tcpi_sndbuf_limited; /* Time (usec) limited by send buffer */
|
|
261
|
+
# };
|
|
262
|
+
#
|
|
263
|
+
# Now nowing types and order of fields we can easily parse binary data
|
|
264
|
+
# by using
|
|
265
|
+
# - `C` flag for `__u8` type - 8-bit unsigned (unsigned char)
|
|
266
|
+
# - `L` flag for `__u32` type - 32-bit unsigned, native endian (uint32_t)
|
|
267
|
+
# - `Q` flag for `__u64` type - 64-bit unsigned, native endian (uint64_t)
|
|
268
|
+
#
|
|
269
|
+
# Complete `unpack` would look like `C8 L24 Q4 L6 Q4`, but we are only
|
|
270
|
+
# interested in `unacked` field at the moment, that's why we only parse
|
|
271
|
+
# till this field by unpacking with `C8 L5`.
|
|
272
|
+
#
|
|
273
|
+
# If you find that it's not giving correct results, then please fall back
|
|
274
|
+
# to inspect, or update this code to accept unpack sequence. But in the
|
|
275
|
+
# end unpack is preferable, as it's 12x faster than inspect.
|
|
276
|
+
#
|
|
277
|
+
# Tested against:
|
|
278
|
+
# - Amazon Linux 2 with kernel 4.14 & 5.10
|
|
279
|
+
# - Ubuntu 20.04 with kernel 5.13
|
|
280
|
+
#
|
|
281
|
+
def parse_with_unpack(tcp_info)
|
|
282
|
+
tcp_info.unpack('C8L5').last
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Puma
|
|
6
|
+
class Plugin
|
|
7
|
+
module TelemetryToo
|
|
8
|
+
module Formatters
|
|
9
|
+
# JSON formatter, expects `call` method accepting telemetry hash
|
|
10
|
+
class JSONFormatter
|
|
11
|
+
def self.call(telemetry)
|
|
12
|
+
::JSON.dump(telemetry)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puma
|
|
4
|
+
class Plugin
|
|
5
|
+
module TelemetryToo
|
|
6
|
+
module Formatters
|
|
7
|
+
# Logfmt formatter, expects `call` method accepting telemetry hash
|
|
8
|
+
class LogfmtFormatter
|
|
9
|
+
def self.call(telemetry)
|
|
10
|
+
telemetry.map { |k, v| "#{String(k)}=#{v.inspect}" }.join(' ')
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puma
|
|
4
|
+
class Plugin
|
|
5
|
+
module TelemetryToo
|
|
6
|
+
module Formatters
|
|
7
|
+
# A pass-through formatter - it returns the telemetry Hash it was given
|
|
8
|
+
class PassthroughFormatter
|
|
9
|
+
def self.call(telemetry)
|
|
10
|
+
telemetry
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../formatters/json_formatter'
|
|
4
|
+
require_relative '../formatters/logfmt_formatter'
|
|
5
|
+
require_relative '../formatters/passthrough_formatter'
|
|
6
|
+
require_relative '../transforms/cloud_watch_transform'
|
|
7
|
+
require_relative '../transforms/l2met_transform'
|
|
8
|
+
require_relative '../transforms/passthrough_transform'
|
|
9
|
+
|
|
10
|
+
module Puma
|
|
11
|
+
class Plugin
|
|
12
|
+
module TelemetryToo
|
|
13
|
+
module Targets
|
|
14
|
+
# A base class for other Targets concerned with formatting telemetry
|
|
15
|
+
class BaseFormattingTarget
|
|
16
|
+
def initialize(formatter: :json, transform: :cloud_watch)
|
|
17
|
+
@formatter = FORMATTERS.fetch(formatter) { formatter }
|
|
18
|
+
@transform = TRANSFORMS.fetch(transform) { transform }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(_telemetry)
|
|
22
|
+
raise "#{__method__} must be implemented by #{self.class.name}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :formatter, :transform
|
|
28
|
+
|
|
29
|
+
FORMATTERS = {
|
|
30
|
+
json: Formatters::JSONFormatter,
|
|
31
|
+
logfmt: Formatters::LogfmtFormatter,
|
|
32
|
+
passthrough: Formatters::PassthroughFormatter
|
|
33
|
+
}.freeze
|
|
34
|
+
private_constant :FORMATTERS
|
|
35
|
+
|
|
36
|
+
TRANSFORMS = {
|
|
37
|
+
cloud_watch: Transforms::CloudWatchTranform,
|
|
38
|
+
l2met: Transforms::L2metTransform,
|
|
39
|
+
passthrough: Transforms::PassthroughTransform
|
|
40
|
+
}.freeze
|
|
41
|
+
private_constant :TRANSFORMS
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puma
|
|
4
|
+
class Plugin
|
|
5
|
+
module TelemetryToo
|
|
6
|
+
module Targets
|
|
7
|
+
# Target wrapping Datadog Statsd client. You can configure
|
|
8
|
+
# all details like _metrics prefix_ and _tags_ in the client
|
|
9
|
+
# itself.
|
|
10
|
+
#
|
|
11
|
+
# ## Example
|
|
12
|
+
#
|
|
13
|
+
# require "datadog/statsd"
|
|
14
|
+
#
|
|
15
|
+
# client = Datadog::Statsd.new(namespace: "ruby.puma",
|
|
16
|
+
# tags: {
|
|
17
|
+
# service: "my-webapp",
|
|
18
|
+
# env: ENV["RAILS_ENV"],
|
|
19
|
+
# version: ENV["CODE_VERSION"]
|
|
20
|
+
# })
|
|
21
|
+
#
|
|
22
|
+
# DatadogStatsdTarget.new(client: client)
|
|
23
|
+
#
|
|
24
|
+
class DatadogStatsdTarget
|
|
25
|
+
def initialize(client:)
|
|
26
|
+
@client = client
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# We are using `gauge` metric type, which means that only the last
|
|
30
|
+
# value will get send to datadog. DD Statsd client is using extra
|
|
31
|
+
# thread since v5 for aggregating metrics before it sends them.
|
|
32
|
+
#
|
|
33
|
+
# This means that we could publish metrics from here several times
|
|
34
|
+
# before they get flushed from the aggregation thread, and when they
|
|
35
|
+
# do, only the last values will get sent.
|
|
36
|
+
#
|
|
37
|
+
# That's why we are explicitly calling flush here, in order to persist
|
|
38
|
+
# all metrics, and not only the most recent ones.
|
|
39
|
+
#
|
|
40
|
+
def call(telemetry)
|
|
41
|
+
telemetry.each do |metric, value|
|
|
42
|
+
@client.gauge(metric, value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@client.flush(sync: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_formatting_target'
|
|
4
|
+
|
|
5
|
+
module Puma
|
|
6
|
+
class Plugin
|
|
7
|
+
module TelemetryToo
|
|
8
|
+
module Targets
|
|
9
|
+
# Simple IO Target, publishing metrics to STDOUT or logs
|
|
10
|
+
class IOTarget < BaseFormattingTarget
|
|
11
|
+
def initialize(io: $stdout, formatter: :json, transform: :cloud_watch)
|
|
12
|
+
super(formatter: formatter, transform: transform)
|
|
13
|
+
@io = io
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(telemetry)
|
|
17
|
+
io.puts(formatter.call(transform.call(telemetry)))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :io
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'base_formatting_target'
|
|
5
|
+
|
|
6
|
+
module Puma
|
|
7
|
+
class Plugin
|
|
8
|
+
module TelemetryToo
|
|
9
|
+
module Targets
|
|
10
|
+
# Simple Log Target, publishing metrics to a Ruby ::Logger at stdout
|
|
11
|
+
# at the INFO log level
|
|
12
|
+
class LogTarget < BaseFormattingTarget
|
|
13
|
+
def initialize(logger: ::Logger.new($stdout), formatter: :logfmt, transform: :passthrough)
|
|
14
|
+
super(formatter: formatter, transform: transform)
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(telemetry)
|
|
19
|
+
logger.info(formatter.call(transform.call(telemetry)))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :logger
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Puma
|
|
6
|
+
class Plugin
|
|
7
|
+
module TelemetryToo
|
|
8
|
+
module Transforms
|
|
9
|
+
# Replace dots with dashes for better support of AWS CloudWatch Log
|
|
10
|
+
# Metric filters, as they don't support dots in key names.
|
|
11
|
+
# Expects `call` method accepting telemetry Hash
|
|
12
|
+
class CloudWatchTranform
|
|
13
|
+
def self.call(telemetry)
|
|
14
|
+
telemetry.transform_keys { |k| String(k).tr('.', '-') }.tap do |data|
|
|
15
|
+
data['name'] = 'Puma::Plugin::TelemetryToo'
|
|
16
|
+
data['message'] = 'Publish telemetry'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Puma
|
|
7
|
+
class Plugin
|
|
8
|
+
module TelemetryToo
|
|
9
|
+
module Transforms
|
|
10
|
+
# L2Met (Logs to Metrics) transform that makes all keys a `sample#` in the L2Met format.
|
|
11
|
+
class L2metTransform
|
|
12
|
+
def self.call(telemetry)
|
|
13
|
+
new.call(telemetry)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(host_env: ENV, program_name: $PROGRAM_NAME, socket: Socket)
|
|
17
|
+
@host_env = host_env
|
|
18
|
+
@program_name = program_name
|
|
19
|
+
@socket = socket
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(telemetry)
|
|
23
|
+
telemetry.transform_keys { |k| "sample##{k}" }.tap do |data|
|
|
24
|
+
data['name'] ||= 'Puma::Plugin::TelemetryToo'
|
|
25
|
+
data['source'] ||= source
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :host_env, :program_name, :socket
|
|
32
|
+
|
|
33
|
+
def source
|
|
34
|
+
@source ||= host_env['L2MET_SOURCE'] ||
|
|
35
|
+
host_env['DYNO'] || # For Heroku
|
|
36
|
+
host_with_exe_name # Last-ditch effort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def host_with_exe_name
|
|
40
|
+
"#{socket.gethostname}/#{Pathname(program_name).basename}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|