puma-plugin-telemetry 1.1.6 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66c4c03c973b8976fe5c41efaa5837ba7dea8acb62e9fdbc789925928864f60c
4
- data.tar.gz: 44d6f8ceb1ad23d0251a59055f0f29b555b4bc74a56ce22722474cb0b9e50603
3
+ metadata.gz: 967086816efd56c3d2237c21240a41fcc0a7fa28314fc181d4afcd570337ea0c
4
+ data.tar.gz: e2dd1fdb9ad91c1e07c22304292492fc68b0450c7ef498851f64a067f872b84a
5
5
  SHA512:
6
- metadata.gz: 07c4c38cade484b8c78c11a63761ed51ab7641ff354d9dab36e3d918a266e92c9a4f477b3b8b0a674776785e089f6a5c29629d24f774076293c268a79043cb6e
7
- data.tar.gz: f8dcbd60fcb5b19409483c26b50fd91559bed7e0addddd5a42ddfc1a369a3d8f16a0a3e018a74ea697742dcabcdd3574ee50d2492fb521fa1190d92915937ab9
6
+ metadata.gz: ffe1e2dba8e463969b8ec987170f233678924ffcf69a6a8d54febe499be623495a95b87326e20bb860745a2842bc2e2fab0a43b03588ad0de75a1711558404e8
7
+ data.tar.gz: c6d86543aaf25ae6768c10b3c68d8511ab1089a6bb9cb6c60f0a67c38d0f57f1387ff21b8be1a784615e0779a4132d09cd6cf05a5eeeae2424986dd2f72f5a57
data/.rubocop.yml CHANGED
@@ -8,3 +8,7 @@ AllCops:
8
8
  Metrics/BlockLength:
9
9
  Exclude:
10
10
  - spec/**/*
11
+
12
+ Metrics/ClassLength:
13
+ Exclude:
14
+ - spec/**/*
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0]
11
+
12
+ ### Added
13
+ - New `:open_telemetry` target. Metric names get the `puma` prefix by default. Requires the `opentelemetry-metrics-sdk` gem in the consumer project (#30)
14
+ - New `formatter:` options for the IO target: `:json` and `:passthrough` (#25)
15
+ - New `transform:` options for the IO target: `:cloud_watch` and `:passthrough` (#25)
16
+
17
+ ### Changed
18
+ - A custom `formatter:` on the IO target now receives the transformed telemetry. Pass `transform: :passthrough` to get the same telemetry as before (#25)
19
+
20
+ ### Dropped
21
+ - The `Targets::IOTarget::JSONFormatter` constant. Its behavior moved to `Formatters::JSONFormatter` and `Transforms::CloudWatchTransform` (#25)
22
+
23
+ ### Fixed
24
+ - Stop the telemetry runner when puma shuts down or when the target IO stream is closed. This prevents `IOError: closed stream` errors during shutdown (#31, #45)
25
+ - Log target errors with `unknown_error` instead of `error`, so a failed publish does not make puma exit (#31, #45)
26
+
10
27
  ## [1.1.6]
11
28
 
12
29
  ### Changed
data/README.md CHANGED
@@ -41,26 +41,67 @@ Puma::Plugin::Telemetry.configure do |config|
41
41
  end
42
42
  ```
43
43
 
44
- ### Basic
44
+ ### Basic IO Target
45
45
 
46
- Output telemetry as JSON to `STDOUT`
46
+ A basic I/O target will emit telemetry data to `STDOUT`, formatted in JSON.
47
47
 
48
48
  ```ruby
49
- config.add_target :io
49
+ config.add_target :io
50
50
  ```
51
51
 
52
+ #### Options
53
+
54
+ This target has configurable `formatter:` and `transform:` options.
55
+ The `formatter:` options are
56
+
57
+ * `:json` _(default)_ - Print the logs in JSON.
58
+ * `:passthrough` - A passthrough formatter which returns the telemetry `Hash` unaltered, passing it directly to the `io:` instance.
59
+
60
+ The `transform:` options are
61
+
62
+ * `:cloud_watch` _(default)_ - Transforms telemetry keys, replacing dots with dashes to support AWS CloudWatch Log Metrics filters.
63
+ * `:passthrough` - A passthrough transform which returns the telemetry `Hash` unaltered.
64
+
52
65
  ### Datadog StatsD target
53
66
 
54
- Given gem provides built in target for Datadog StatsD client, that uses batch operation to publish metrics.
67
+ A target for the Datadog StatsD client, that uses batch operation to publish metrics.
55
68
 
56
- **NOTE** Be sure to have `dogstatsd` gem installed.
69
+ **NOTE** This target requires the `dogstatsd-ruby` gem in your project:
57
70
 
58
71
  ```ruby
59
- config.add_target :dogstatsd, client: Datadog::Statsd.new
72
+ gem "dogstatsd-ruby"
73
+ ```
74
+
75
+ ```ruby
76
+ config.add_target :dogstatsd, client: Datadog::Statsd.new
60
77
  ```
61
78
 
62
79
  You can provide all the tags, namespaces, and other configuration options as always to `Datadog::Statsd.new` method.
63
80
 
81
+ ### OpenTelemetry target
82
+
83
+ A target for the OpenTelemetry Metrics SDK, that uses batch operations to publish metrics.
84
+
85
+ **NOTE** This target requires the `opentelemetry-metrics-sdk` gem in your project:
86
+
87
+ ```ruby
88
+ gem "opentelemetry-metrics-sdk"
89
+ ```
90
+
91
+ ```ruby
92
+ config.add_target :open_telemetry, meter_provider: OpenTelemetry.meter_provider
93
+ ```
94
+
95
+ This target supports the following options:
96
+
97
+ | Option | Description | Default | Required |
98
+ |----------------|----------------------------------------------------------------------------------------------------------------------------------------|---------|----------|
99
+ | meter_provider | The meter provider used to create instruments, e.g. `OpenTelemetry.meter_provider` | - | Yes |
100
+ | prefix | Metric name prefix. <br> ex) prefix: 'web' => 'web.workers.booted'. Pass `nil` for no prefix | 'puma' | No |
101
+ | suffix | Metric name suffix. <br> ex) suffix: 'v1' => 'workers.booted.v1' | nil | No |
102
+ | attributes | Attributes to be included with the metric | {} | No |
103
+ | force_flush | Force flush the meter provider after each publish, so all values are exported, not only the last aggregated one. Can impact performance | false | No |
104
+
64
105
  ### All available options
65
106
 
66
107
  For detailed documentation checkout [`Puma::Plugin::Telemetry::Config`](./lib/puma/plugin/telemetry/config.rb) class.
@@ -73,7 +114,7 @@ Puma::Plugin::Telemetry.configure do |config|
73
114
  config.puma_telemetry = %w[workers.requests_count queue.backlog queue.capacity]
74
115
  config.socket_telemetry!
75
116
  config.socket_parser = :inspect
76
- config.add_target :io, formatter: :json, io: StringIO.new
117
+ config.add_target :io, io: StringIO.new, formatter: :json, transform: :passthrough
77
118
  config.add_target :dogstatsd, client: Datadog::Statsd.new(tags: { env: ENV["RAILS_ENV"] })
78
119
  end
79
120
  ```
@@ -85,8 +126,8 @@ Target is a simple object that implements `call` methods that accepts `telemetry
85
126
  Just be mindful that if the API takes long to call, it will slow down frequency with which telemetry will get reported.
86
127
 
87
128
  ```ruby
88
- # Example logfmt to stdout target
89
- config.add_target proc { |telemetry| puts telemetry.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") }
129
+ # Example key/value log to `STDOUT` target
130
+ config.add_target ->(telemetry) { puts telemetry.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") }
90
131
  ```
91
132
 
92
133
  ## Extra middleware
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'socket'
4
+
3
5
  module Puma
4
6
  class Plugin
5
7
  module Telemetry
@@ -32,7 +34,8 @@ module Puma
32
34
 
33
35
  TARGETS = {
34
36
  dogstatsd: Telemetry::Targets::DatadogStatsdTarget,
35
- io: Telemetry::Targets::IOTarget
37
+ io: Telemetry::Targets::IOTarget,
38
+ open_telemetry: Telemetry::Targets::OpenTelemetryTarget
36
39
  }.freeze
37
40
 
38
41
  # Whenever telemetry should run with puma
@@ -91,7 +94,15 @@ module Puma
91
94
  end
92
95
 
93
96
  def socket_telemetry!
94
- @socket_telemetry = true
97
+ # These structs are platform specific, and not available on macOS,
98
+ # for example. If they're undefined, then we cannot capture socket
99
+ # telemetry. We'll warn in that case.
100
+ if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_INFO)
101
+ @socket_telemetry = true
102
+ else
103
+ @socket_telemetry = false
104
+ warn("Cannot capture socket telemetry on this platform (#{RUBY_PLATFORM}); socket_telemetry is disabled.")
105
+ end
95
106
  end
96
107
 
97
108
  def socket_telemetry?
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Puma
6
+ class Plugin
7
+ module Telemetry
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 Telemetry
6
+ module Formatters
7
+ # A passthrough 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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../formatters/json_formatter'
4
+ require_relative '../formatters/passthrough_formatter'
5
+ require_relative '../transforms/cloud_watch_transform'
6
+ require_relative '../transforms/passthrough_transform'
7
+
8
+ module Puma
9
+ class Plugin
10
+ module Telemetry
11
+ module Targets
12
+ # A base class for other Targets concerned with formatting telemetry
13
+ class BaseFormattingTarget
14
+ def initialize(formatter: :json, transform: :cloud_watch)
15
+ @transform = case transform
16
+ when :cloud_watch then Transforms::CloudWatchTransform
17
+ when :passthrough then Transforms::PassthroughTransform
18
+ else transform
19
+ end
20
+ @formatter = case formatter
21
+ when :json then Formatters::JSONFormatter
22
+ when :passthrough then Formatters::PassthroughFormatter
23
+ else formatter
24
+ end
25
+ end
26
+
27
+ def call(_telemetry)
28
+ raise NotImplementedError, "#{__method__} must be implemented by #{self.class.name}"
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :formatter, :transform
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,40 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require_relative 'base_formatting_target'
4
4
 
5
5
  module Puma
6
6
  class Plugin
7
7
  module Telemetry
8
8
  module Targets
9
9
  # Simple IO Target, publishing metrics to STDOUT or logs
10
- #
11
- class IOTarget
12
- # JSON formatter for IO, expects `call` method accepting telemetry hash
13
- #
14
- class JSONFormatter
15
- # NOTE: Replace dots with dashes for better support of AWS CloudWatch
16
- # Log Metric filters, as they don't support dots in key names.
17
- def self.call(telemetry)
18
- log = telemetry.transform_keys { |k| k.tr('.', '-') }
19
-
20
- log['name'] = 'Puma::Plugin::Telemetry'
21
- log['message'] = 'Publish telemetry'
22
-
23
- ::JSON.dump(log)
24
- end
25
- end
26
-
27
- def initialize(io: $stdout, formatter: :json)
10
+ class IOTarget < BaseFormattingTarget
11
+ def initialize(io: $stdout, formatter: :json, transform: :cloud_watch)
12
+ super(formatter: formatter, transform: transform)
28
13
  @io = io
29
- @formatter = case formatter
30
- when :json then JSONFormatter
31
- else formatter
32
- end
33
14
  end
34
15
 
35
16
  def call(telemetry)
36
- @io.puts(@formatter.call(telemetry))
17
+ io.puts(formatter.call(transform.call(telemetry)))
37
18
  end
19
+
20
+ private
21
+
22
+ attr_reader :io
38
23
  end
39
24
  end
40
25
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module Telemetry
6
+ module Targets
7
+ # Target wrapping OpenTelemetry Metrics client.
8
+ #
9
+ # ## Example
10
+ #
11
+ # require 'opentelemetry-metrics-sdk'
12
+ #
13
+ # OpenTelemetryTarget.new(meter_provider: OpenTelemetry.meter_provider)
14
+ #
15
+ class OpenTelemetryTarget
16
+ def initialize(meter_provider:, prefix: 'puma', suffix: nil, force_flush: false, attributes: {})
17
+ @meter_provider = meter_provider
18
+ @meter = meter_provider.meter('puma.telemetry')
19
+ @prefix = prefix
20
+ @suffix = suffix
21
+ @force_flush = force_flush
22
+ @attributes = attributes
23
+ @instruments = {}
24
+ end
25
+
26
+ # We are using `gauge` metric type, which means that only the last value will get exported
27
+ # since the OpenTelemetry exporter aggregates metrics before sending them.
28
+ #
29
+ # This means that we could publish metrics from here several times
30
+ # before they get flushed from the aggregation thread, and when they
31
+ # do, only the last values will get sent.
32
+ #
33
+ # That's why we provide the option to explicitly call force_flush here, in order to persist
34
+ # all metrics, and not only the most recent ones.
35
+ #
36
+ # Note: Force flushing metrics every time can significantly impact performance
37
+ #
38
+ def call(telemetry)
39
+ telemetry.each do |metric, value|
40
+ instrument(metric).record(value, attributes: @attributes)
41
+ end
42
+
43
+ @meter_provider.force_flush if @force_flush
44
+ end
45
+
46
+ def instrument(metric)
47
+ @instruments[metric] ||= @meter.create_gauge([@prefix, metric, @suffix].compact.join('.'))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module Telemetry
6
+ module Transforms
7
+ # Replace dots with dashes for better support of AWS CloudWatch Log
8
+ # Metric filters, as they don't support dots in key names.
9
+ # Expects `call` method accepting telemetry Hash
10
+ class CloudWatchTransform
11
+ def self.call(telemetry)
12
+ telemetry.transform_keys { |k| String(k).tr('.', '-') }.tap do |data|
13
+ data['name'] = 'Puma::Plugin::Telemetry'
14
+ data['message'] = 'Publish telemetry'
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ class Plugin
5
+ module Telemetry
6
+ module Transforms
7
+ # A passthrough transform - it returns the telemetry Hash it was given
8
+ class PassthroughTransform
9
+ def self.call(telemetry)
10
+ telemetry
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -3,7 +3,7 @@
3
3
  module Puma
4
4
  class Plugin
5
5
  module Telemetry
6
- VERSION = '1.1.6'
6
+ VERSION = '1.2.0'
7
7
  end
8
8
  end
9
9
  end
@@ -7,6 +7,7 @@ require 'puma/plugin/telemetry/version'
7
7
  require 'puma/plugin/telemetry/data'
8
8
  require 'puma/plugin/telemetry/targets/datadog_statsd_target'
9
9
  require 'puma/plugin/telemetry/targets/io_target'
10
+ require 'puma/plugin/telemetry/targets/open_telemetry_target'
10
11
  require 'puma/plugin/telemetry/config'
11
12
 
12
13
  module Puma
@@ -64,6 +65,7 @@ module Puma
64
65
  module PluginInstanceMethods
65
66
  def start(launcher)
66
67
  @launcher = launcher
68
+ @stopped = false
67
69
 
68
70
  unless Puma::Plugin::Telemetry.config.enabled?
69
71
  log_writer.log 'plugin=telemetry msg="disabled, exiting..."'
@@ -72,26 +74,27 @@ module Puma
72
74
 
73
75
  log_writer.log 'plugin=telemetry msg="enabled, setting up runner..."'
74
76
 
75
- in_background do
76
- sleep Puma::Plugin::Telemetry.config.initial_delay
77
- run!
78
- end
77
+ setup_runner
79
78
  end
80
79
 
81
80
  def run!
82
81
  loop do
83
- log_writer.debug 'plugin=telemetry msg="publish"'
84
-
85
- call(Puma::Plugin::Telemetry.build(@launcher))
86
- rescue Errno::EPIPE
87
- # Occurs when trying to output to STDOUT while puma is shutting down
88
- rescue StandardError => e
89
- log_writer.error "plugin=telemetry err=#{e.class} msg=#{e.message.inspect}"
90
- ensure
82
+ break if stopped?
83
+
84
+ break unless try_publish
85
+
91
86
  sleep Puma::Plugin::Telemetry.config.frequency
92
87
  end
93
88
  end
94
89
 
90
+ def stop!
91
+ @stopped = true
92
+ end
93
+
94
+ def stopped?
95
+ !!@stopped
96
+ end
97
+
95
98
  def call(telemetry)
96
99
  Puma::Plugin::Telemetry.config.targets.each do |target|
97
100
  target.call(telemetry)
@@ -100,6 +103,39 @@ module Puma
100
103
 
101
104
  private
102
105
 
106
+ def setup_runner
107
+ @launcher.events.on_stopped { stop! }
108
+
109
+ in_background do
110
+ sleep Puma::Plugin::Telemetry.config.initial_delay
111
+ run!
112
+ end
113
+ end
114
+
115
+ def try_publish
116
+ publish
117
+ true
118
+ rescue IOError, Errno::EPIPE
119
+ # Puma closes the IO streams during shutdown, stop publishing
120
+ log_safely 'plugin=telemetry msg="IO stream closed, stopping the runner"'
121
+ false
122
+ rescue StandardError => e
123
+ log_writer.unknown_error(e, nil, 'plugin=telemetry')
124
+ true
125
+ end
126
+
127
+ def publish
128
+ log_writer.debug 'plugin=telemetry msg="publish"'
129
+
130
+ call(Puma::Plugin::Telemetry.build(@launcher))
131
+ end
132
+
133
+ def log_safely(message)
134
+ log_writer.log(message)
135
+ rescue IOError, Errno::EPIPE
136
+ nil
137
+ end
138
+
103
139
  def log_writer
104
140
  if Puma::Const::PUMA_VERSION.to_i < 6
105
141
  @launcher.events
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: puma-plugin-telemetry
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.6
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leszek Zalewski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-03 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: puma
@@ -51,8 +51,14 @@ files:
51
51
  - lib/puma/plugin/telemetry.rb
52
52
  - lib/puma/plugin/telemetry/config.rb
53
53
  - lib/puma/plugin/telemetry/data.rb
54
+ - lib/puma/plugin/telemetry/formatters/json_formatter.rb
55
+ - lib/puma/plugin/telemetry/formatters/passthrough_formatter.rb
56
+ - lib/puma/plugin/telemetry/targets/base_formatting_target.rb
54
57
  - lib/puma/plugin/telemetry/targets/datadog_statsd_target.rb
55
58
  - lib/puma/plugin/telemetry/targets/io_target.rb
59
+ - lib/puma/plugin/telemetry/targets/open_telemetry_target.rb
60
+ - lib/puma/plugin/telemetry/transforms/cloud_watch_transform.rb
61
+ - lib/puma/plugin/telemetry/transforms/passthrough_transform.rb
56
62
  - lib/puma/plugin/telemetry/version.rb
57
63
  - lib/rack/request_queue_time_middleware.rb
58
64
  - puma-plugin-telemetry.gemspec