delivery_boy 2.0.0.alpha.1 → 2.0.0.alpha.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8bca84f797ae282f1568a53d1542f0bca409e56eaf27c079607e191ccb8841a
4
- data.tar.gz: 012ee10f7057df815147ce29bb135d3f8397f1454a9fdff95afa72194c06e884
3
+ metadata.gz: a4dac7da9e416e7c55878674f91006d43b73811e27b1aa05897604d48c6e3162
4
+ data.tar.gz: b5536e1e66d7c8882fe6452b552f50f2fa4e37846870bea0c6b6f5ceef399028
5
5
  SHA512:
6
- metadata.gz: 9381929d414fba52aabadc9d483c478bf1968b357db832825d57eedfd214f544cc95fdfee169ae54fb10f07f45ac485f22d3bc17198a614b649295b8341e9363
7
- data.tar.gz: 598705aa9268eb0985262958ca4d2d1979d57a06bac366e99113ae302fb6935d5104053389f97236cdfb714430642a383284707b2a5ad9fbdc1615658671e371
6
+ metadata.gz: 8390928820e66c27e3ec762344df31d0e4101831e0f1cca21b2289939fbc23cad4f554b17314eb7f92d48e7f3bd1b6d90ebfceac3e3b41065dbabacf3f2aa0b6
7
+ data.tar.gz: 2bf809365bd9f693cf1dd03f61e6cb37c5c43682087ecc013cd0626d558a83bdc04b4dd4b065dc6a9e0170479dc192f2b26f56cc3501ce77aa820b351dddb817
@@ -0,0 +1,4 @@
1
+ # CODEOWNERS file
2
+ # This file defines who should review code changes in this repository.
3
+
4
+ * @zendesk/core-gem-owners
data/CHANGELOG CHANGED
@@ -1,6 +1,52 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## Unreleased (v2.0.0)
4
+
5
+ ### Migration to librdkafka
6
+
7
+ * Migrated from ruby-kafka to rdkafka (librdkafka) for improved performance and stability
8
+
9
+ ### New Features
10
+
11
+ * PLAIN and SCRAM-SHA-256/512 SASL authentication now fully functional with librdkafka
12
+ * Added `sasl_username` and `sasl_password` as consolidated config options (work for both PLAIN and SCRAM)
13
+ * Added automatic `security.protocol` detection (PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL)
14
+ * Backward compatible SASL configuration - existing mechanism-specific options (sasl_plain_username, sasl_scram_username) continue to work
15
+ * OAUTHBEARER authentication via OIDC configuration (librdkafka handles token refresh automatically)
16
+
17
+ ### Breaking Changes & Known Limitations
18
+
19
+ * **AWS MSK IAM authentication is no longer supported** (librdkafka limitation)
20
+ - Alternative 1: Use AWS MSK SCRAM-SHA-512 authentication (recommended)
21
+ - Alternative 2: Use mTLS (mutual TLS) with client certificates
22
+ * **OAUTHBEARER authentication changed from callback-based to OIDC configuration**
23
+ - `sasl_oauth_token_provider` is no longer supported
24
+ - Use new OIDC config options instead: `sasl_oauthbearer_method`, `sasl_oauthbearer_client_id`, `sasl_oauthbearer_client_secret`, `sasl_oauthbearer_token_endpoint_url`
25
+ - librdkafka handles token refresh automatically (no custom code needed)
26
+ * `sasl_plain_authzid` is not supported by librdkafka and will be ignored with a warning
27
+ * `compression_threshold` is no longer supported - librdkafka applies compression automatically to all batches when `compression_codec` is set
28
+
29
+ ### Config options no longer used
30
+
31
+ The following config options are silently ignored (no error, but no effect):
32
+ * `max_queue_size` - rdkafka manages its own internal queue
33
+ * `ssl_ca_certs_from_system` - no librdkafka equivalent
34
+ * `ssl_verify_hostname` - not mapped to librdkafka
35
+ * `sasl_scram_mechanism` - use `sasl_mechanism` instead
36
+ * `log_level` - rdkafka has its own logging configuration
37
+
38
+ ### Datadog Integration
39
+
40
+ * Datadog metrics continue to work with the same metric names as before
41
+ * Now uses `DeliveryBoy::Datadog` instead of `Kafka::Datadog`
42
+ * Instrumentation is built on top of ActiveSupport::Notifications
43
+ * Some metrics are no longer available due to rdkafka's different architecture:
44
+ - `producer.buffer.fill_ratio` / `producer.buffer.fill_percentage`
45
+ - `producer.deliver.attempts`
46
+ - `producer.ack.delay`
47
+ - `async_producer.queue.fill_ratio`
48
+
49
+ ### Other Changes
4
50
 
5
51
  * `compression_codec` in the `DeliveryBoy::Config` is now coerces its value into
6
52
  a symbol if the value is present.
data/Gemfile CHANGED
@@ -10,3 +10,5 @@ gem "rake", "~> 13.0"
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "standard", "~> 1.51.1" if RUBY_VERSION > "3.0"
12
12
  gem "testcontainers-kafka", github: "testcontainers/testcontainers-ruby"
13
+ gem "activesupport"
14
+ gem "dogstatsd-ruby"
data/README.md CHANGED
@@ -172,7 +172,7 @@ The codec used to compress messages. Must be either `snappy` or `gzip`.
172
172
 
173
173
  ##### `compression_threshold`
174
174
 
175
- The minimum number of messages that must be buffered before compression is attempted. By default only one message is required. Only relevant if `compression_codec` is set.
175
+ **Deprecated:** This setting has no effect with librdkafka. Compression is applied automatically to all batches when `compression_codec` is set.
176
176
 
177
177
  #### Network
178
178
 
@@ -226,88 +226,136 @@ The password required to read the ssl_client_cert_key. Must be used in combinati
226
226
 
227
227
  #### SASL Authentication and authorization
228
228
 
229
- See [ruby-kafka](https://github.com/zendesk/ruby-kafka#authentication-using-sasl) for more information.
229
+ DeliveryBoy supports SASL authentication with multiple mechanisms.
230
230
 
231
- Use it through `GSSAPI`, `PLAIN` _or_ `OAUTHBEARER`.
231
+ ##### GSSAPI (Kerberos)
232
232
 
233
- ##### `sasl_gssapi_principal`
233
+ ###### `sasl_gssapi_principal`
234
234
 
235
235
  The GSSAPI principal.
236
236
 
237
- ##### `sasl_gssapi_keytab`
237
+ ###### `sasl_gssapi_keytab`
238
238
 
239
239
  Optional GSSAPI keytab.
240
240
 
241
- ##### `sasl_plain_authzid`
241
+ **Example:**
242
242
 
243
- The authorization identity to use.
243
+ ```ruby
244
+ DeliveryBoy.configure do |config|
245
+ config.sasl_mechanism = "GSSAPI"
246
+ config.sasl_gssapi_principal = "kafka/hostname@REALM"
247
+ config.sasl_gssapi_keytab = "/path/to/keytab"
248
+ end
249
+ ```
250
+
251
+ ##### PLAIN Authentication
252
+
253
+ ###### `sasl_plain_username`
254
+
255
+ The username used to authenticate (legacy option).
244
256
 
245
- ##### `sasl_plain_username`
257
+ ###### `sasl_plain_password`
246
258
 
247
- The username used to authenticate.
259
+ The password used to authenticate (legacy option).
248
260
 
249
- ##### `sasl_plain_password`
261
+ ###### `sasl_username`
250
262
 
251
- The password used to authenticate.
263
+ The username used to authenticate (new consolidated option, works for both PLAIN and SCRAM).
252
264
 
253
- ##### `sasl_oauth_token_provider`
265
+ ###### `sasl_password`
254
266
 
255
- A instance of a class which implements the `token` method.
256
- As described in [ruby-kafka](https://github.com/zendesk/ruby-kafka/tree/c3e90bc355fad1e27b9af1048966ff08d3d5735b#oauthbearer)
267
+ The password used to authenticate (new consolidated option, works for both PLAIN and SCRAM).
268
+
269
+ **Example:**
257
270
 
258
271
  ```ruby
259
- class TokenProvider
260
- def token
261
- "oauth-token"
262
- end
272
+ DeliveryBoy.configure do |config|
273
+ config.sasl_mechanism = "PLAIN"
274
+ # You can use either the new consolidated options:
275
+ config.sasl_username = "your-username"
276
+ config.sasl_password = "your-password"
277
+ # Or the legacy mechanism-specific options:
278
+ # config.sasl_plain_username = "your-username"
279
+ # config.sasl_plain_password = "your-password"
263
280
  end
281
+ ```
282
+
283
+ ##### SCRAM Authentication
284
+
285
+ Supports SCRAM-SHA-256 and SCRAM-SHA-512 mechanisms.
286
+
287
+ ###### `sasl_scram_username`
264
288
 
289
+ The username used to authenticate (legacy option).
290
+
291
+ ###### `sasl_scram_password`
292
+
293
+ The password used to authenticate (legacy option).
294
+
295
+ **Example:**
296
+
297
+ ```ruby
265
298
  DeliveryBoy.configure do |config|
266
- config.sasl_oauth_token_provider = TokenProvider.new
267
- config.ssl_ca_certs_from_system = true
299
+ config.sasl_mechanism = "SCRAM-SHA-256" # or "SCRAM-SHA-512"
300
+ # You can use either the new consolidated options:
301
+ config.sasl_username = "your-username"
302
+ config.sasl_password = "your-password"
303
+ # Or the legacy mechanism-specific options:
304
+ # config.sasl_scram_username = "your-username"
305
+ # config.sasl_scram_password = "your-password"
268
306
  end
269
307
  ```
270
308
 
271
- #### AWS MSK IAM Authentication and Authorization
309
+ ##### OAUTHBEARER (OIDC)
310
+
311
+ OAUTHBEARER authentication is supported via OIDC configuration. librdkafka handles token acquisition and refresh automatically.
312
+
313
+ ###### `sasl_oauthbearer_method`
272
314
 
273
- ##### sasl_aws_msk_iam_access_key_id
315
+ Set to `"oidc"` to enable OIDC-based OAUTHBEARER authentication.
274
316
 
275
- The AWS IAM access key. Required.
317
+ ###### `sasl_oauthbearer_client_id`
276
318
 
277
- ##### sasl_aws_msk_iam_secret_key_id
319
+ The OAuth client ID for your application.
278
320
 
279
- The AWS IAM secret access key. Required.
321
+ ###### `sasl_oauthbearer_client_secret`
280
322
 
281
- ##### sasl_aws_msk_iam_aws_region
323
+ The OAuth client secret for your application.
282
324
 
283
- The AWS region. Required.
325
+ ###### `sasl_oauthbearer_token_endpoint_url`
284
326
 
285
- ##### sasl_aws_msk_iam_session_token
327
+ The URL of the OAuth token endpoint (e.g., `https://auth.example.com/oauth/token`).
286
328
 
287
- The session token. This value can be optional.
329
+ ###### `sasl_oauthbearer_scope` (optional)
288
330
 
289
- ###### Examples
331
+ OAuth scope to request.
290
332
 
291
- Using a role arn and web identity token to generate temporary credentials:
333
+ ###### `sasl_oauthbearer_extensions` (optional)
334
+
335
+ Additional SASL extensions as comma-separated key=value pairs.
292
336
 
293
337
  ```ruby
294
- require "aws-sdk-core"
295
- require "delivery_boy"
296
-
297
- role = Aws::AssumeRoleWebIdentityCredentials.new(
298
- role_arn: ENV["AWS_ROLE_ARN"],
299
- web_identity_token_file: ENV["AWS_WEB_IDENTITY_TOKEN_FILE"]
300
- )
301
-
302
- DeliveryBoy.configure do |c|
303
- c.sasl_aws_msk_iam_access_key_id = role.credentials.access_key_id
304
- c.sasl_aws_msk_iam_secret_key_id = role.credentials.secret_access_key
305
- c.sasl_aws_msk_iam_session_token = role.credentials.session_token
306
- c.sasl_aws_msk_iam_aws_region = ENV["AWS_REGION"]
307
- c.ssl_ca_certs_from_system = true
338
+ DeliveryBoy.configure do |config|
339
+ config.sasl_mechanism = "OAUTHBEARER"
340
+ config.sasl_oauthbearer_method = "oidc"
341
+ config.sasl_oauthbearer_client_id = "your-client-id"
342
+ config.sasl_oauthbearer_client_secret = "your-client-secret"
343
+ config.sasl_oauthbearer_token_endpoint_url = "https://auth.example.com/oauth/token"
344
+ # Optional:
345
+ # config.sasl_oauthbearer_scope = "kafka"
308
346
  end
309
347
  ```
310
348
 
349
+ **Note:** The legacy `sasl_oauth_token_provider` callback is no longer supported. Use OIDC configuration instead.
350
+
351
+ #### AWS MSK IAM Authentication
352
+
353
+ **Note:** AWS MSK IAM authentication is not supported by librdkafka.
354
+
355
+ **Recommended alternatives:**
356
+ 1. **Use AWS MSK SCRAM authentication** - Create SCRAM credentials in AWS Secrets Manager and use SCRAM-SHA-512
357
+ 2. **Use mTLS** - Configure mutual TLS with client certificates
358
+
311
359
  ### Testing
312
360
 
313
361
  DeliveryBoy provides a test mode out of the box. When this mode is enabled, messages will be stored in memory rather than being sent to Kafka. If you use RSpec, enabling test mode is as easy as adding this to your spec helper:
data/delivery_boy.gemspec CHANGED
@@ -19,7 +19,6 @@ Gem::Specification.new do |spec|
19
19
 
20
20
  spec.require_paths = ["lib"]
21
21
 
22
- # spec.add_runtime_dependency "ruby-kafka", "~> 1.0"
23
22
  spec.add_runtime_dependency "king_konf", "~> 1.0"
24
23
  spec.add_runtime_dependency "rdkafka", "> 0.11"
25
24
  end
@@ -16,10 +16,6 @@ module DeliveryBoy
16
16
  transactional_timeout * 1000
17
17
  end
18
18
 
19
- def isolation_level
20
- transactional ? "read_uncommitted" : "read_committed"
21
- end
22
-
23
19
  def max_buffer_kbytesize
24
20
  max_buffer_bytesize / 1024
25
21
  end
@@ -28,7 +24,36 @@ module DeliveryBoy
28
24
  delivery_interval * 1000
29
25
  end
30
26
 
31
- def sasl_username
27
+ def ack_timeout_ms
28
+ ack_timeout * 1000
29
+ end
30
+
31
+ def retry_backoff_ms
32
+ retry_backoff * 1000
33
+ end
34
+
35
+ def sasl_enabled?
36
+ return false unless sasl_mechanism && !sasl_mechanism.empty?
37
+ sasl_mechanism.upcase != "GSSAPI"
38
+ end
39
+
40
+ def validate_aws_msk_iam!
41
+ if sasl_aws_msk_iam_access_key_id || sasl_aws_msk_iam_secret_key_id || sasl_aws_msk_iam_aws_region
42
+ raise ConfigError, <<~ERROR
43
+ AWS MSK IAM authentication is not supported by librdkafka.
44
+
45
+ Alternatives:
46
+ 1. Use AWS MSK SCRAM-SHA-512 authentication (recommended)
47
+ - Create SCRAM credentials in AWS Secrets Manager
48
+ - Set sasl_mechanism = "SCRAM-SHA-512"
49
+ - Set sasl_username and sasl_password
50
+
51
+ 2. Use mTLS (mutual TLS) with client certificates
52
+ - Configure ssl_client_cert and ssl_client_cert_key
53
+
54
+ See migration guide: https://github.com/zendesk/delivery_boy/blob/master/MIGRATION.md#aws-msk-iam
55
+ ERROR
56
+ end
32
57
  end
33
58
 
34
59
  # Basic
@@ -36,10 +61,10 @@ module DeliveryBoy
36
61
  string :client_id, default: "delivery_boy"
37
62
  string :log_level, default: nil
38
63
 
39
- # Buffering
64
+ # Buffering (defaults match librdkafka defaults to avoid queue overflow at high throughput)
40
65
  integer :max_buffer_bytesize, default: 10_000_000
41
- integer :max_buffer_size, default: 1000
42
- integer :max_queue_size, default: 1000
66
+ integer :max_buffer_size, default: 100_000
67
+ integer :max_queue_size, default: 100_000
43
68
 
44
69
  # Network timeouts
45
70
  integer :connect_timeout, default: 10
@@ -71,7 +96,7 @@ module DeliveryBoy
71
96
  boolean :ssl_verify_hostname, default: true
72
97
 
73
98
  # Supported: GSSAPI, PLAIN, SCRAM-SHA-256, SCRAM-SHA-512, OAUTHBEARER
74
- string :sasl_mechanism, default: "GSSAPI"
99
+ string :sasl_mechanism, default: nil
75
100
  # SASL authentication
76
101
  string :sasl_gssapi_principal
77
102
  string :sasl_gssapi_keytab
@@ -83,9 +108,21 @@ module DeliveryBoy
83
108
  string :sasl_scram_mechanism
84
109
  boolean :sasl_over_ssl, default: true
85
110
 
86
- # SASL OAUTHBEARER
111
+ # New consolidated SASL options (librdkafka-aligned)
112
+ string :sasl_username, default: nil
113
+ string :sasl_password, default: nil
114
+
115
+ # SASL OAUTHBEARER (legacy - callback-based, not supported with librdkafka)
87
116
  attr_accessor :sasl_oauth_token_provider
88
117
 
118
+ # SASL OAUTHBEARER OIDC (librdkafka native support)
119
+ string :sasl_oauthbearer_method, default: nil # "oidc" for OIDC-based auth
120
+ string :sasl_oauthbearer_client_id, default: nil
121
+ string :sasl_oauthbearer_client_secret, default: nil
122
+ string :sasl_oauthbearer_token_endpoint_url, default: nil
123
+ string :sasl_oauthbearer_scope, default: nil
124
+ string :sasl_oauthbearer_extensions, default: nil
125
+
89
126
  # AWS IAM authentication
90
127
  string :sasl_aws_msk_iam_access_key_id
91
128
  string :sasl_aws_msk_iam_secret_key_id
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "datadog/statsd"
5
+ rescue LoadError
6
+ warn "In order to report Kafka client metrics to Datadog you need to install the `dogstatsd-ruby` gem."
7
+ raise
8
+ end
9
+
10
+ require "active_support/subscriber"
11
+
12
+ module DeliveryBoy
13
+ # Reports operational metrics to a Datadog agent using the Statsd protocol.
14
+ #
15
+ # require "delivery_boy/datadog"
16
+ #
17
+ # # Default is "ruby_kafka" (kept for backward compatibility).
18
+ # DeliveryBoy::Datadog.namespace = "custom-namespace"
19
+ #
20
+ # # Default is "127.0.0.1".
21
+ # DeliveryBoy::Datadog.host = "statsd.something.com"
22
+ #
23
+ # # Default is 8125.
24
+ # DeliveryBoy::Datadog.port = 1234
25
+ #
26
+ module Datadog
27
+ STATSD_NAMESPACE = "ruby_kafka"
28
+
29
+ class << self
30
+ attr_reader :host, :port, :socket_path
31
+
32
+ def configure
33
+ yield self
34
+ end
35
+
36
+ def statsd
37
+ @statsd ||= if socket_path
38
+ ::Datadog::Statsd.new(socket_path: socket_path, namespace: namespace, tags: tags)
39
+ else
40
+ ::Datadog::Statsd.new(host, port, namespace: namespace, tags: tags)
41
+ end
42
+ end
43
+
44
+ def statsd=(statsd)
45
+ clear
46
+ @statsd = statsd
47
+ end
48
+
49
+ def host=(host)
50
+ @host = host
51
+ clear
52
+ end
53
+
54
+ def port=(port)
55
+ @port = port
56
+ clear
57
+ end
58
+
59
+ def socket_path=(socket_path)
60
+ @socket_path = socket_path
61
+ clear
62
+ end
63
+
64
+ def namespace
65
+ @namespace ||= STATSD_NAMESPACE
66
+ end
67
+
68
+ def namespace=(namespace)
69
+ @namespace = namespace
70
+ clear
71
+ end
72
+
73
+ def tags
74
+ @tags ||= []
75
+ end
76
+
77
+ def tags=(tags)
78
+ @tags = tags
79
+ clear
80
+ end
81
+
82
+ def close
83
+ @statsd&.close
84
+ end
85
+
86
+ private
87
+
88
+ def clear
89
+ close
90
+ @statsd = nil
91
+ end
92
+ end
93
+
94
+ class StatsdSubscriber < ActiveSupport::Subscriber
95
+ private
96
+
97
+ %w[increment histogram count timing gauge].each do |type|
98
+ define_method(type) do |*args, **kwargs|
99
+ emit(type, *args, **kwargs)
100
+ end
101
+ end
102
+
103
+ def emit(type, *args, tags: {})
104
+ tags = tags.map { |k, v| "#{k}:#{v}" }.to_a
105
+ DeliveryBoy::Datadog.statsd.send(type, *args, tags: tags)
106
+ end
107
+ end
108
+
109
+ class ProducerSubscriber < StatsdSubscriber
110
+ def produce_message(event)
111
+ client = event.payload.fetch(:client_id)
112
+ topic = event.payload.fetch(:topic)
113
+ message_size = event.payload.fetch(:message_size)
114
+ buffer_size = event.payload.fetch(:buffer_size)
115
+
116
+ tags = {client: client, topic: topic}
117
+
118
+ if event.payload.key?(:exception)
119
+ increment("producer.produce.errors", tags: tags)
120
+ else
121
+ increment("producer.produce.messages", tags: tags)
122
+ histogram("producer.produce.message_size", message_size, tags: tags)
123
+ count("producer.produce.message_size.sum", message_size, tags: tags)
124
+ histogram("producer.buffer.size", buffer_size, tags: tags)
125
+ end
126
+ end
127
+
128
+ def deliver_messages(event)
129
+ client = event.payload.fetch(:client_id)
130
+ message_count = event.payload.fetch(:delivered_message_count)
131
+
132
+ tags = {client: client}
133
+
134
+ increment("producer.deliver.errors", tags: tags) if event.payload.key?(:exception)
135
+ timing("producer.deliver.latency", event.duration, tags: tags)
136
+ count("producer.deliver.messages", message_count, tags: tags)
137
+ end
138
+
139
+ def deliver(event)
140
+ client = event.payload.fetch(:client_id)
141
+ topic = event.payload.fetch(:topic)
142
+ message_size = event.payload.fetch(:message_size)
143
+
144
+ tags = {client: client, topic: topic}
145
+
146
+ if event.payload.key?(:exception)
147
+ increment("producer.deliver.errors", tags: tags)
148
+ else
149
+ increment("producer.produce.messages", tags: tags)
150
+ histogram("producer.produce.message_size", message_size, tags: tags)
151
+ count("producer.produce.message_size.sum", message_size, tags: tags)
152
+ timing("producer.deliver.latency", event.duration, tags: tags)
153
+ count("producer.deliver.messages", 1, tags: tags)
154
+ end
155
+ end
156
+
157
+ def deliver_async(event)
158
+ client = event.payload.fetch(:client_id)
159
+ topic = event.payload.fetch(:topic)
160
+ message_size = event.payload.fetch(:message_size)
161
+ queue_size = event.payload.fetch(:queue_size, 0)
162
+
163
+ tags = {client: client, topic: topic}
164
+
165
+ if event.payload.key?(:exception)
166
+ increment("async_producer.produce.errors", tags: tags)
167
+ else
168
+ increment("producer.produce.messages", tags: tags)
169
+ histogram("producer.produce.message_size", message_size, tags: tags)
170
+ count("producer.produce.message_size.sum", message_size, tags: tags)
171
+ histogram("async_producer.queue.size", queue_size, tags: tags)
172
+ end
173
+ end
174
+
175
+ def ack_message(event)
176
+ tags = {
177
+ client: event.payload.fetch(:client_id),
178
+ topic: event.payload.fetch(:topic)
179
+ }
180
+
181
+ increment("producer.ack.messages", tags: tags)
182
+ end
183
+
184
+ def delivery_error(event)
185
+ tags = {client: event.payload.fetch(:client_id)}
186
+ increment("producer.ack.errors", tags: tags)
187
+ end
188
+
189
+ attach_to "delivery_boy"
190
+ end
191
+ end
192
+ end
@@ -1,11 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeliveryBoy
2
4
  # This class implements the actual logic of DeliveryBoy. The DeliveryBoy module
3
5
  # has a module-level singleton instance.
4
6
  class Instance
5
- def initialize(config, logger)
7
+ def initialize(config, logger, instrumenter: NullInstrumenter.new)
6
8
  @config = config
7
9
  @logger = logger
8
- @handles = []
10
+ @instrumenter = instrumenter
9
11
  end
10
12
 
11
13
  def deliver(value, topic:, **options)
@@ -15,9 +17,19 @@ module DeliveryBoy
15
17
  options_clone.delete(:create_time)
16
18
  end
17
19
 
18
- sync_producer
19
- .produce(payload: value, topic: topic, **options_clone)
20
- .wait
20
+ message_size = value.to_s.bytesize
21
+
22
+ instrumentation_payload = {
23
+ client_id: config.client_id,
24
+ topic: topic,
25
+ message_size: message_size
26
+ }
27
+
28
+ @instrumenter.instrument("deliver", instrumentation_payload) do
29
+ sync_producer
30
+ .produce(payload: value, topic: topic, **options_clone)
31
+ .wait
32
+ end
21
33
  end
22
34
 
23
35
  def deliver_async!(value, topic:, **options)
@@ -27,8 +39,19 @@ module DeliveryBoy
27
39
  options_clone.delete(:create_time)
28
40
  end
29
41
 
30
- async_producer
31
- .produce(payload: value, topic: topic, **options_clone)
42
+ message_size = value.to_s.bytesize
43
+
44
+ instrumentation_payload = {
45
+ client_id: config.client_id,
46
+ topic: topic,
47
+ message_size: message_size,
48
+ queue_size: async_producer_queue_size
49
+ }
50
+
51
+ @instrumenter.instrument("deliver_async", instrumentation_payload) do
52
+ async_producer
53
+ .produce(payload: value, topic: topic, **options_clone)
54
+ end
32
55
  end
33
56
 
34
57
  def shutdown
@@ -37,17 +60,43 @@ module DeliveryBoy
37
60
  end
38
61
 
39
62
  def produce(value, topic:, **options)
40
- handle = sync_producer.produce(payload: value, topic: topic, **options)
41
- handles.push(handle)
63
+ options_clone = options.clone
64
+ if options[:create_time]
65
+ options_clone[:timestamp] = Time.at(options[:create_time])
66
+ options_clone.delete(:create_time)
67
+ end
68
+
69
+ message_size = value.to_s.bytesize
70
+
71
+ instrumentation_payload = {
72
+ client_id: config.client_id,
73
+ topic: topic,
74
+ message_size: message_size,
75
+ buffer_size: handles.size
76
+ }
77
+
78
+ @instrumenter.instrument("produce_message", instrumentation_payload) do
79
+ handle = sync_producer.produce(payload: value, topic: topic, **options_clone)
80
+ handles.push(handle)
81
+ end
42
82
  end
43
83
 
44
84
  def deliver_messages
45
- handles.each(&:wait)
46
- handles.clear
85
+ message_count = handles.size
86
+
87
+ instrumentation_payload = {
88
+ client_id: config.client_id,
89
+ delivered_message_count: message_count
90
+ }
91
+
92
+ @instrumenter.instrument("deliver_messages", instrumentation_payload) do
93
+ sync_producer.flush
94
+ handles.clear
95
+ end
47
96
  end
48
97
 
49
98
  def clear_buffer
50
- handles.clear_buffer
99
+ handles.clear
51
100
  end
52
101
 
53
102
  def buffer_size
@@ -71,70 +120,240 @@ module DeliveryBoy
71
120
  def async_producer
72
121
  # The async producer doesn't have to be per-thread, since all deliveries are
73
122
  # performed by a single background thread.
74
- @async_producer ||= Rdkafka::Config.new({
75
- "bootstrap.servers": config.brokers.join(","),
76
- "queue.buffering.backpressure.threshold": config.delivery_threshold,
77
- "queue.buffering.max.ms": config.delivery_interval_ms
78
- }.merge(producer_options)).producer
123
+ @async_producer ||= begin
124
+ producer = Rdkafka::Config.new({
125
+ "bootstrap.servers": config.brokers.join(","),
126
+ "queue.buffering.backpressure.threshold": config.delivery_threshold,
127
+ "queue.buffering.max.ms": config.delivery_interval_ms
128
+ }.merge(producer_options)).producer
129
+
130
+ producer.delivery_callback = delivery_callback
131
+ producer
132
+ end
79
133
  end
80
134
 
81
135
  def async_producer?
82
136
  !@async_producer.nil?
83
137
  end
84
138
 
139
+ def async_producer_queue_size
140
+ return 0 unless async_producer?
141
+ # rdkafka doesn't expose queue size directly, return 0 as approximation
142
+ 0
143
+ end
144
+
145
+ def delivery_callback
146
+ instrumenter = @instrumenter
147
+ client_id = config.client_id
148
+
149
+ proc do |delivery_report|
150
+ if delivery_report.error
151
+ instrumenter.instrument("delivery_error", {
152
+ client_id: client_id,
153
+ error: delivery_report.error
154
+ })
155
+ else
156
+ instrumenter.instrument("ack_message", {
157
+ client_id: client_id,
158
+ topic: delivery_report.topic_name,
159
+ partition: delivery_report.partition,
160
+ offset: delivery_report.offset
161
+ })
162
+ end
163
+ end
164
+ end
165
+
85
166
  def kafka
86
167
  @kafka ||= Rdkafka::Config.new({
87
168
  "bootstrap.servers": config.brokers.join(",")
88
169
  }.merge(producer_options))
89
170
  end
90
171
 
91
- # Options for both the sync and async producers.
172
+ def sasl_options
173
+ return {} unless config.sasl_mechanism && !config.sasl_mechanism.empty?
174
+
175
+ config.validate_aws_msk_iam! if config.sasl_enabled?
176
+
177
+ options = {}
178
+
179
+ mechanism = config.sasl_mechanism.upcase
180
+
181
+ case mechanism
182
+ when "GSSAPI"
183
+ options.merge!(gssapi_options)
184
+ when "PLAIN"
185
+ options.merge!(plain_options)
186
+ when "SCRAM-SHA-256", "SCRAM-SHA-512"
187
+ options["sasl.mechanism"] = mechanism
188
+ options.merge!(scram_options)
189
+ when "OAUTHBEARER"
190
+ options.merge!(oauthbearer_options)
191
+ else
192
+ logger.warn "Unknown SASL mechanism: #{config.sasl_mechanism}"
193
+ end
194
+
195
+ options.compact
196
+ end
197
+
198
+ def gssapi_options
199
+ {
200
+ "sasl.mechanism" => "GSSAPI",
201
+ "sasl.kerberos.principal" => config.sasl_gssapi_principal,
202
+ "sasl.kerberos.keytab" => config.sasl_gssapi_keytab
203
+ }
204
+ end
205
+
206
+ def plain_options
207
+ username = config.sasl_username || config.sasl_plain_username
208
+ password = config.sasl_password || config.sasl_plain_password
209
+
210
+ if username.nil? || username.to_s.empty? || password.nil? || password.to_s.empty?
211
+ raise ConfigError, "PLAIN authentication requires sasl_username and sasl_password to be set"
212
+ end
213
+
214
+ # Note: sasl_plain_authzid doesn't have a librdkafka equivalent
215
+ # Log warning if set, but don't fail
216
+ if config.sasl_plain_authzid && !config.sasl_plain_authzid.empty?
217
+ logger.warn "sasl_plain_authzid is not supported by librdkafka and will be ignored"
218
+ end
219
+
220
+ {
221
+ "sasl.mechanism" => "PLAIN",
222
+ "sasl.username" => username,
223
+ "sasl.password" => password
224
+ }
225
+ end
226
+
227
+ def scram_options
228
+ username = config.sasl_username || config.sasl_scram_username
229
+ password = config.sasl_password || config.sasl_scram_password
230
+
231
+ if username.nil? || username.to_s.empty? || password.nil? || password.to_s.empty?
232
+ raise ConfigError, "SCRAM authentication requires sasl_username and sasl_password to be set"
233
+ end
234
+
235
+ {
236
+ "sasl.username" => username,
237
+ "sasl.password" => password
238
+ }
239
+ end
240
+
241
+ def oauthbearer_options
242
+ # Check for legacy token provider (not supported)
243
+ if config.sasl_oauth_token_provider
244
+ raise ConfigError, <<~ERROR
245
+ sasl_oauth_token_provider is no longer supported with librdkafka.
246
+
247
+ Migration options:
248
+ 1. Use OIDC configuration (recommended for OIDC providers like Auth0, Okta):
249
+ config.sasl_oauthbearer_method = "oidc"
250
+ config.sasl_oauthbearer_client_id = "your-client-id"
251
+ config.sasl_oauthbearer_client_secret = "your-client-secret"
252
+ config.sasl_oauthbearer_token_endpoint_url = "https://auth.example.com/oauth/token"
253
+
254
+ 2. Use SCRAM-SHA-256/512 as an alternative authentication method.
255
+
256
+ See: https://github.com/zendesk/delivery_boy/blob/master/MIGRATION.md#oauthbearer
257
+ ERROR
258
+ end
259
+
260
+ if config.sasl_oauthbearer_method&.downcase == "oidc"
261
+ if config.sasl_oauthbearer_client_id.nil? || config.sasl_oauthbearer_client_id.empty?
262
+ raise ConfigError, "OAUTHBEARER OIDC requires sasl_oauthbearer_client_id to be set"
263
+ end
264
+ if config.sasl_oauthbearer_client_secret.nil? || config.sasl_oauthbearer_client_secret.empty?
265
+ raise ConfigError, "OAUTHBEARER OIDC requires sasl_oauthbearer_client_secret to be set"
266
+ end
267
+ if config.sasl_oauthbearer_token_endpoint_url.nil? || config.sasl_oauthbearer_token_endpoint_url.empty?
268
+ raise ConfigError, "OAUTHBEARER OIDC requires sasl_oauthbearer_token_endpoint_url to be set"
269
+ end
270
+ else
271
+ raise ConfigError, <<~ERROR
272
+ OAUTHBEARER requires OIDC configuration.
273
+
274
+ Set the following options:
275
+ config.sasl_oauthbearer_method = "oidc"
276
+ config.sasl_oauthbearer_client_id = "your-client-id"
277
+ config.sasl_oauthbearer_client_secret = "your-client-secret"
278
+ config.sasl_oauthbearer_token_endpoint_url = "https://auth.example.com/oauth/token"
279
+ ERROR
280
+ end
281
+
282
+ options = {
283
+ "sasl.mechanism" => "OAUTHBEARER",
284
+ "sasl.oauthbearer.method" => "oidc",
285
+ "sasl.oauthbearer.client.id" => config.sasl_oauthbearer_client_id,
286
+ "sasl.oauthbearer.client.secret" => config.sasl_oauthbearer_client_secret,
287
+ "sasl.oauthbearer.token.endpoint.url" => config.sasl_oauthbearer_token_endpoint_url
288
+ }
289
+
290
+ options["sasl.oauthbearer.scope"] = config.sasl_oauthbearer_scope if config.sasl_oauthbearer_scope
291
+ options["sasl.oauthbearer.extensions"] = config.sasl_oauthbearer_extensions if config.sasl_oauthbearer_extensions
292
+
293
+ options
294
+ end
295
+
296
+ def security_protocol
297
+ has_ssl = config.ssl_ca_cert || config.ssl_ca_cert_file_path
298
+ has_sasl = config.sasl_enabled? || config.sasl_gssapi_principal
299
+
300
+ if config.sasl_over_ssl == false && has_ssl
301
+ raise ConfigError, <<~ERROR
302
+ sasl_over_ssl=false with SSL certificates configured is not supported by librdkafka.
303
+
304
+ librdkafka's security.protocol cannot express "SSL for verification but SASL over plaintext".
305
+
306
+ Options:
307
+ 1. Remove SSL certificate configuration to use SASL_PLAINTEXT
308
+ 2. Remove sasl_over_ssl=false to use SASL_SSL (recommended)
309
+
310
+ Note: sasl_over_ssl is deprecated and will be removed in a future version.
311
+ ERROR
312
+ end
313
+
314
+ if has_sasl && has_ssl
315
+ "SASL_SSL"
316
+ elsif has_sasl
317
+ "SASL_PLAINTEXT"
318
+ elsif has_ssl
319
+ "SSL"
320
+ else
321
+ "PLAINTEXT"
322
+ end
323
+ end
324
+
92
325
  def producer_options
93
326
  if config.transactional? && config.transactional_id.nil?
94
327
  raise "transactional_id must be set"
95
328
  end
96
329
 
97
330
  {
331
+ "client.id": config.client_id,
98
332
  "socket.connection.setup.timeout.ms": config.connection_timeout_ms,
99
333
  "socket.timeout.ms": config.socket_timeout_ms,
100
334
  "request.required.acks": config.required_acks,
101
- "request.timeout.ms": config.ack_timeout,
335
+ "request.timeout.ms": config.ack_timeout_ms,
102
336
  "message.send.max.retries": config.max_retries,
103
- "retry.backoff.ms": config.retry_backoff,
337
+ "retry.backoff.ms": config.retry_backoff_ms,
104
338
  "queue.buffering.max.messages": config.max_buffer_size,
105
- "queue.buffering.max.kbytes": config.max_buffer_bytesize,
339
+ "queue.buffering.max.kbytes": config.max_buffer_kbytesize,
106
340
  "compression.codec": config.compression_codec, # values none, gzip, snappy, lz4, zstd
107
341
  "enable.idempotence": config.idempotent,
108
342
  "transactional.id": config.transactional_id,
109
343
  "transaction.timeout.ms": config.transactional_timeout_ms,
110
-
111
- # SSL options
344
+ "security.protocol": security_protocol,
112
345
  "ssl.ca.pem": config.ssl_ca_cert,
113
346
  "ssl.ca.location": config.ssl_ca_cert_file_path,
114
347
  "ssl.certificate.pem": config.ssl_client_cert,
115
348
  "ssl.key.pem": config.ssl_client_cert_key,
116
- "ssl.key.password": config.ssl_client_cert_key_password,
349
+ "ssl.key.password": config.ssl_client_cert_key_password
117
350
  # ssl_ca_certs_from_system: config.ssl_ca_certs_from_system, # TODO: there is no corresponding librdkafka option. check what this does
118
351
  # ssl_verify_hostname: config.ssl_verify_hostname, # check
119
- "sasl.kerberos.principal": config.sasl_gssapi_principal,
120
- "sasl.kerberos.keytab": config.sasl_gssapi_keytab
121
- # sasl_plain_authzid: config.sasl_plain_authzid, # no corresponding librdkafka option, check
122
- # 'sasl.username': config.sasl_plain_username,
123
- # 'sasl.password': config.sasl_plain_password,
124
- # 'sasl.username': config.sasl_scram_username,
125
- # 'sasl.passord': config.sasl_scram_password,
126
- # 'sasl.mechanism': config.sasl_scram_mechanism,
127
- # sasl_over_ssl: config.sasl_over_ssl, # conditional value check again
128
- # sasl_oauth_token_provider: config.sasl_oauth_token_provider, # cb code
129
- # sasl_aws_msk_iam_access_key_id: config.sasl_aws_msk_iam_access_key_id, # not supported
130
- # sasl_aws_msk_iam_secret_key_id: config.sasl_aws_msk_iam_secret_key_id, # not supported
131
- # sasl_aws_msk_iam_session_token: config.sasl_aws_msk_iam_session_token, # not supported
132
- # sasl_aws_msk_iam_aws_region: config.sasl_aws_msk_iam_aws_region # not supported
133
- }
352
+ }.merge(sasl_options)
134
353
  end
135
354
 
136
- private
137
-
138
- attr_reader :handles
355
+ def handles
356
+ Thread.current[:delivery_boy_handles] ||= []
357
+ end
139
358
  end
140
359
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeliveryBoy
4
+ class Instrumenter
5
+ NAMESPACE = "delivery_boy"
6
+
7
+ def initialize(default_payload: {})
8
+ require "active_support/notifications"
9
+ @default_payload = default_payload
10
+ end
11
+
12
+ def instrument(event_name, payload = {}, &block)
13
+ ActiveSupport::Notifications.instrument(
14
+ "#{event_name}.#{NAMESPACE}",
15
+ @default_payload.merge(payload),
16
+ &block
17
+ )
18
+ end
19
+ end
20
+
21
+ class NullInstrumenter
22
+ def instrument(*, &block)
23
+ block&.call
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeliveryBoy
2
4
  class Railtie < Rails::Railtie
3
5
  initializer "delivery_boy.load_config" do
@@ -12,12 +14,15 @@ module DeliveryBoy
12
14
  end
13
15
 
14
16
  if config.datadog_enabled
15
- require "kafka/datadog"
17
+ require "delivery_boy/datadog"
18
+
19
+ DeliveryBoy::Datadog.host = config.datadog_host if config.datadog_host.present?
20
+ DeliveryBoy::Datadog.port = config.datadog_port if config.datadog_port.present?
21
+ DeliveryBoy::Datadog.namespace = config.datadog_namespace if config.datadog_namespace.present?
22
+ DeliveryBoy::Datadog.tags = config.datadog_tags if config.datadog_tags.present?
16
23
 
17
- Kafka::Datadog.host = config.datadog_host if config.datadog_host.present?
18
- Kafka::Datadog.port = config.datadog_port if config.datadog_port.present?
19
- Kafka::Datadog.namespace = config.datadog_namespace if config.datadog_namespace.present?
20
- Kafka::Datadog.tags = config.datadog_tags if config.datadog_tags.present?
24
+ # Enable instrumentation
25
+ DeliveryBoy.instrumenter = DeliveryBoy::Instrumenter.new(default_payload: {})
21
26
  end
22
27
  end
23
28
  end
@@ -1,3 +1,3 @@
1
1
  module DeliveryBoy
2
- VERSION = "2.0.0.alpha.1"
2
+ VERSION = "2.0.0.alpha.2"
3
3
  end
data/lib/delivery_boy.rb CHANGED
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "logger"
2
- # require "kafka"
3
4
  require "delivery_boy/version"
5
+ require "delivery_boy/instrumenter"
4
6
  require "delivery_boy/instance"
5
7
  require "delivery_boy/fake"
6
8
  require "delivery_boy/config"
@@ -23,20 +25,19 @@ module DeliveryBoy
23
25
  # @param partition_key [String, nil] a key used to deterministically assign
24
26
  # a partition to the message.
25
27
  # @return [nil]
26
- # @raise [Kafka::BufferOverflow] if the producer's buffer is full.
27
- # @raise [Kafka::DeliveryFailed] if delivery failed for some reason.
28
+ # @raise [Rdkafka::RdkafkaError] if delivery failed for some reason.
28
29
  def deliver(value, topic:, **options)
29
30
  instance.deliver(value, topic: topic, **options)
30
31
  end
31
32
 
32
- # Like {.deliver_async!}, but handles +Kafka::BufferOverflow+ errors
33
+ # Like {.deliver_async!}, but handles +Rdkafka::RdkafkaError+ errors
33
34
  # by logging them and just going on with normal business.
34
35
  #
35
36
  # @return [nil]
36
37
  def deliver_async(value, topic:, **options)
37
38
  deliver_async!(value, topic: topic, **options)
38
- rescue Kafka::BufferOverflow
39
- logger.error "Message for `#{topic}` dropped due to buffer overflow"
39
+ rescue Rdkafka::RdkafkaError => e
40
+ logger.error "Message for `#{topic}` dropped due to error: #{e.message}"
40
41
  end
41
42
 
42
43
  # Like {.deliver}, but returns immediately.
@@ -48,14 +49,14 @@ module DeliveryBoy
48
49
  instance.deliver_async!(value, topic: topic, **options)
49
50
  end
50
51
 
51
- # Like {.produce!}, but handles +Kafka::BufferOverflow+ errors
52
+ # Like {.produce!}, but handles +Rdkafka::RdkafkaError+ errors
52
53
  # by logging them and just going on with normal business.
53
54
  #
54
55
  # @return [nil]
55
56
  def produce(value, topic:, **options)
56
57
  produce!(value, topic: topic, **options)
57
- rescue Kafka::BufferOverflow
58
- logger.error "Message for `#{topic}` dropped due to buffer overflow"
58
+ rescue Rdkafka::RdkafkaError => e
59
+ logger.error "Message for `#{topic}` dropped due to error: #{e.message}"
59
60
  end
60
61
 
61
62
  # Appends the given message to the producer buffer but does not send it until {.deliver_messages} is called.
@@ -68,7 +69,7 @@ module DeliveryBoy
68
69
  # @param partition_key [String, nil] a key used to deterministically assign
69
70
  # a partition to the message.
70
71
  # @return [nil]
71
- # @raise [Kafka::BufferOverflow] if the producer's buffer is full.
72
+ # @raise [Rdkafka::RdkafkaError] if the producer's buffer is full.
72
73
  def produce!(value, topic:, **options)
73
74
  instance.produce(value, topic: topic, **options)
74
75
  end
@@ -76,7 +77,7 @@ module DeliveryBoy
76
77
  # Delivers the items currently in the producer buffer.
77
78
  #
78
79
  # @return [nil]
79
- # @raise [Kafka::DeliveryFailed] if delivery failed for some reason.
80
+ # @raise [Rdkafka::RdkafkaError] if delivery failed for some reason.
80
81
  def deliver_messages
81
82
  instance.deliver_messages
82
83
  end
@@ -114,6 +115,15 @@ module DeliveryBoy
114
115
 
115
116
  attr_writer :logger
116
117
 
118
+ # The instrumenter used by DeliveryBoy for emitting metrics.
119
+ #
120
+ # @return [DeliveryBoy::Instrumenter, DeliveryBoy::NullInstrumenter]
121
+ def instrumenter
122
+ @instrumenter ||= NullInstrumenter.new
123
+ end
124
+
125
+ attr_writer :instrumenter
126
+
117
127
  # The configuration used by DeliveryBoy.
118
128
  #
119
129
  # @return [DeliveryBoy::Config]
@@ -150,7 +160,7 @@ module DeliveryBoy
150
160
  private
151
161
 
152
162
  def instance
153
- @instance ||= Instance.new(config, logger)
163
+ @instance ||= Instance.new(config, logger, instrumenter: instrumenter)
154
164
  end
155
165
  end
156
166
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: delivery_boy
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.alpha.1
4
+ version: 2.0.0.alpha.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Schierbeck
@@ -44,8 +44,8 @@ executables: []
44
44
  extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
+ - ".github/CODEOWNERS"
47
48
  - ".github/workflows/ci.yml"
48
- - ".github/workflows/codeql.yaml"
49
49
  - ".github/workflows/publish.yml"
50
50
  - ".github/workflows/stale.yml"
51
51
  - ".gitignore"
@@ -65,8 +65,10 @@ files:
65
65
  - lib/delivery_boy.rb
66
66
  - lib/delivery_boy/config.rb
67
67
  - lib/delivery_boy/config_error.rb
68
+ - lib/delivery_boy/datadog.rb
68
69
  - lib/delivery_boy/fake.rb
69
70
  - lib/delivery_boy/instance.rb
71
+ - lib/delivery_boy/instrumenter.rb
70
72
  - lib/delivery_boy/railtie.rb
71
73
  - lib/delivery_boy/rspec.rb
72
74
  - lib/delivery_boy/version.rb
@@ -1,19 +0,0 @@
1
- name: "CodeQL public repository scanning"
2
-
3
- on:
4
- push:
5
- schedule:
6
- - cron: "0 0 * * *"
7
- pull_request_target:
8
- types: [opened, synchronize, reopened]
9
- workflow_dispatch:
10
-
11
- permissions:
12
- contents: read
13
- security-events: write
14
- actions: read
15
- packages: read
16
-
17
- jobs:
18
- trigger-codeql:
19
- uses: zendesk/prodsec-code-scanning/.github/workflows/codeql_advanced_shared.yml@production