logstash-logger 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +5 -3
  3. data/Appraisals +2 -4
  4. data/CHANGELOG.md +33 -0
  5. data/Gemfile +2 -1
  6. data/README.md +2 -2
  7. data/docs/buffering.md +16 -0
  8. data/docs/outputs.md +23 -0
  9. data/docs/rails.md +7 -2
  10. data/docs/ssl.md +49 -37
  11. data/docs/troubleshooting.md +22 -0
  12. data/gemfiles/rails_8.0.gemfile +3 -1
  13. data/gemfiles/rails_8.1.gemfile +2 -1
  14. data/lib/logstash-logger/buffer.rb +15 -4
  15. data/lib/logstash-logger/device/aws_stream.rb +96 -17
  16. data/lib/logstash-logger/device/connectable.rb +27 -1
  17. data/lib/logstash-logger/device/firehose.rb +6 -0
  18. data/lib/logstash-logger/device/http.rb +75 -4
  19. data/lib/logstash-logger/device/kafka.rb +27 -0
  20. data/lib/logstash-logger/device/kinesis.rb +10 -1
  21. data/lib/logstash-logger/device/tcp.rb +53 -7
  22. data/lib/logstash-logger/formatter/base.rb +44 -3
  23. data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
  24. data/lib/logstash-logger/version.rb +1 -1
  25. data/logstash-logger.gemspec +2 -6
  26. data/spec/device/aws_stream_limits_spec.rb +219 -0
  27. data/spec/device/connectable_spec.rb +51 -0
  28. data/spec/device/firehose_spec.rb +4 -1
  29. data/spec/device/http_spec.rb +166 -1
  30. data/spec/device/kafka_spec.rb +92 -0
  31. data/spec/device/kinesis_spec.rb +4 -1
  32. data/spec/device/tcp_spec.rb +94 -5
  33. data/spec/device/tcp_tls_integration_spec.rb +91 -0
  34. data/spec/formatter/base_spec.rb +42 -6
  35. data/spec/formatter/cee_syslog_spec.rb +71 -19
  36. data/spec/formatter/framing_spec.rb +58 -0
  37. data/spec/formatter/json_lines_spec.rb +18 -0
  38. data/spec/spec_helper.rb +5 -0
  39. data/spec/support/tls_helper.rb +30 -0
  40. data/spec/support/unserializable_value.rb +10 -0
  41. metadata +19 -7
  42. data/gemfiles/rails_7.2.gemfile +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec434ced077d0200f2735e5e2f0ccfb54c5969f5b8790f68ce8178b472d60833
4
- data.tar.gz: 56bb45aab80a9640c7fea253aad346f3db7d1adf2194c0f95721a1f61229fe3b
3
+ metadata.gz: 4f311cb07a6b81dd87a40cd78b4bbfba8742575dcfc888f1cfa85e647b7512c4
4
+ data.tar.gz: ce6a47b4dce5299041b138e97b4923d67a7efaf7816352135a049ab044d90843
5
5
  SHA512:
6
- metadata.gz: e6dc9be621c4710115af8fe97c23983c2f4bdebbaa865db38334f02b5f27bd56ec639032c1d5696788b650879b16d758370a8de3684d0763df36f73a36d99066
7
- data.tar.gz: f1c642f5c57118087142aaed68cc760cfc769646f7168baa1558048f0ae0b2665024e8deb09d15086c4e1a247a34b6be78abe13341f2e46af34e42921b71910f
6
+ metadata.gz: c91bfa3f1b0e569c2744fdce29fbd6097021c0f552e743139aeb98af818ead41eae66bdc2290e353c8a6213b1ec7556f325deaf254f7fe92c3e3589305ee7a6b
7
+ data.tar.gz: db8c4ddee010de0876eb2eb9066d8faf0c2fc8604ae7bc1655526d9c7a86f2bd4fffea02267dfdaa3dd1acbd820479bf50cf3ed9e00b92fcbdb3c724e251881d
@@ -16,9 +16,10 @@ jobs:
16
16
  strategy:
17
17
  fail-fast: false
18
18
  matrix:
19
- ruby: ['3.2', '3.3', '3.4', '4.0', 'jruby-10']
20
- rails: ['7.2', '8.0', '8.1']
19
+ ruby: ['3.3', '3.4', '4.0', 'jruby-10']
20
+ rails: ['8.0', '8.1']
21
21
  runs-on: ubuntu-latest
22
+ timeout-minutes: 30
22
23
  steps:
23
24
  - uses: actions/checkout@v4
24
25
 
@@ -44,8 +45,9 @@ jobs:
44
45
  bundle exec appraisal rails-${{ matrix.rails }} rake default
45
46
 
46
47
  - name: Upload coverage to Codecov
47
- if: matrix.ruby == '3.2' && matrix.rails == '8.1'
48
+ if: matrix.ruby == '3.3' && matrix.rails == '8.1'
48
49
  uses: codecov/codecov-action@v3
49
50
  with:
50
51
  token: ${{ secrets.CODECOV_TOKEN }}
52
+ files: ./coverage/coverage.xml
51
53
  fail_ci_if_error: false
data/Appraisals CHANGED
@@ -1,9 +1,7 @@
1
- appraise "rails-7.2" do
2
- gem "rails", "~> 7.2.0"
3
- end
4
-
5
1
  appraise "rails-8.0" do
6
2
  gem "rails", "~> 8.0.0"
3
+ # ActiveSupport < 8.1 passes quirks_mode to JSON, which json 3.x rejects
4
+ gem "json", "< 3"
7
5
  end
8
6
 
9
7
  appraise "rails-8.1" do
data/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## 1.1.0
2
+
3
+ ### Security
4
+ - The TCP output with `ssl_enable: true` and no `ssl_context` accepted any server certificate, so anyone able to intercept the connection could read or alter the log stream. It now verifies the certificate and hostname. [GHSA-p7gr-ppfj-g45v](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-p7gr-ppfj-g45v)
5
+ - Logged content could break record framing: inject a line break into the `cee_syslog` header, or trigger an unframed `Failed to format log event` string that merged with the next record. [GHSA-88vf-w58m-gv7h](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-88vf-w58m-gv7h)
6
+ - One record that Kafka, Kinesis, Firehose or an HTTP collector always rejects could make the output hold back, duplicate or discard the other records buffered with it. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
7
+
8
+ ### Breaking Changes
9
+ - Drop support for Ruby 3.2 and Rails 7.2, which are end of life. [#185](https://github.com/dwbutler/logstash-logger/pull/185)
10
+ - `ssl_enable: true` without an `ssl_context` now verifies the server certificate against the system trust store. For a self-signed or private-CA server, pass its certificate as `ssl_certificate`. Turning verification off requires an explicit `VERIFY_NONE` context. See [SSL Configuration](docs/ssl.md). [GHSA-p7gr-ppfj-g45v](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-p7gr-ppfj-g45v)
11
+ - `ssl_certificate` is now the certificate trusted for the server, as documented, and is checked when the logger is created. [GHSA-p7gr-ppfj-g45v](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-p7gr-ppfj-g45v)
12
+ - The `cee_syslog` header always uses this machine's hostname. A `host` set in logged content or in `customize_event` appears only in the JSON body. [GHSA-88vf-w58m-gv7h](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-88vf-w58m-gv7h)
13
+
14
+ ### Features
15
+ - `format: :ndjson` option for the HTTP device, to send newline-delimited JSON instead of a JSON array, and a `content_type` option. [#182](https://github.com/dwbutler/logstash-logger/pull/182) [#180](https://github.com/dwbutler/logstash-logger/issues/180)
16
+ - `max_record_bytes` option to drop any formatted record over a size limit, for every buffered output. Off by default. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
17
+ - `aws_max_record_bytes` option for Kinesis streams configured for records larger than 1 MiB. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
18
+ - The TCP output sends SNI for TLS connections to DNS hostnames. [GHSA-p7gr-ppfj-g45v](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-p7gr-ppfj-g45v)
19
+
20
+ ### Bug Fixes
21
+ - HTTP device raises on non-2xx responses, so failed uploads are retried instead of silently dropped. [#184](https://github.com/dwbutler/logstash-logger/pull/184) [#181](https://github.com/dwbutler/logstash-logger/issues/181)
22
+ - HTTP device drops only the records a collector rejects with 400, 413, 415 or 422. Other error statuses are retried. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
23
+ - Kinesis and Firehose enforce the service's record and request limits, retry failed requests instead of treating them as delivered, and requeue throttled records without losing the batch. Kinesis `InternalFailure` records are retried. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
24
+ - Kafka drops only records the broker always rejects, instead of retrying the whole batch until the buffer fills. [GHSA-qcm4-6phg-mhpf](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-qcm4-6phg-mhpf)
25
+ - The TCP output closes the underlying TCP socket when a TLS connection is closed or fails to set up. [GHSA-p7gr-ppfj-g45v](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-p7gr-ppfj-g45v)
26
+ - A record that cannot be formatted is replaced by a placeholder event tagged `_logstashlogger_format_failure`, framed like any other record. [GHSA-88vf-w58m-gv7h](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-88vf-w58m-gv7h)
27
+ - An `@timestamp` in logged content that is not a valid time no longer fails the record. [GHSA-88vf-w58m-gv7h](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-88vf-w58m-gv7h)
28
+ - The `cee_syslog` formatter no longer shares the program name between concurrent log calls. [GHSA-88vf-w58m-gv7h](https://github.com/dwbutler/logstash-logger/security/advisories/GHSA-88vf-w58m-gv7h)
29
+
30
+ ### Other
31
+ - CI covers Ruby 3.3, 3.4, 4.0 and JRuby 10 with Rails 8.0 and 8.1, and uploads coverage to Codecov. [#183](https://github.com/dwbutler/logstash-logger/pull/183) [#185](https://github.com/dwbutler/logstash-logger/pull/185)
32
+ - Fix an order-dependent Kafka spec. [#186](https://github.com/dwbutler/logstash-logger/pull/186)
33
+
1
34
  ## 1.0.0
2
35
 
3
36
  ### Breaking Changes
data/Gemfile CHANGED
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
4
 
5
- gem "codeclimate-test-reporter", group: :test, require: nil
5
+ gem "simplecov", ">= 0.22", group: :test, require: false
6
+ gem "simplecov-cobertura", group: :test, require: false
data/README.md CHANGED
@@ -35,10 +35,10 @@ Or install it yourself as:
35
35
 
36
36
  Verified to work with:
37
37
 
38
- * MRI Ruby 3.2, 3.3, 3.4, 4.0
38
+ * MRI Ruby 3.3, 3.4, 4.0
39
39
  * JRuby 10.x
40
40
 
41
- Ruby versions < 3.2 are EOL'ed and no longer supported.
41
+ Ruby versions < 3.3 are EOL'ed and no longer supported.
42
42
 
43
43
  ## Documentation
44
44
 
data/docs/buffering.md CHANGED
@@ -18,6 +18,7 @@ You can configure buffering behavior by passing the following options to LogStas
18
18
  * `:sync` - Flush buffer every time a message is received (blocking). Defaults to false.
19
19
  * `:buffer_flush_at_exit` - Flush messages when exiting the program. Defaults to true.
20
20
  * `:buffer_logger` - Logger to write buffer debug/error messages to. Defaults to none.
21
+ * `:max_record_bytes` - Drop any single formatted record larger than this many bytes, with a warning that gives its size. Off by default.
21
22
 
22
23
  You can turn buffering off by setting `sync = true`.
23
24
 
@@ -54,6 +55,21 @@ If you don't want to lose messages when the buffer gets full, you can set
54
55
  `drop_messages_on_full_buffer = false`. Note that if the buffer gets full, any
55
56
  incoming log message will block, which could be undesirable.
56
57
 
58
+ ## Records that can never be delivered
59
+
60
+ Some outputs can reject a record for a reason that will not go away on retry, such as its size.
61
+ Instead of retrying the whole batch until the buffer fills, these outputs drop only the rejected
62
+ records, and log their size and the reason, never their content:
63
+
64
+ * Kafka: records the broker or client rejects with `MessageSizeTooLarge`, `RecordListTooLarge`
65
+ or `BufferOverflow`. The rest of the batch is sent one record at a time.
66
+ * Kinesis and Firehose: records over the service's per-record limit (Kinesis 1 MiB by default,
67
+ Firehose 1,000 KiB). Batches are split to fit each service's request limits. If your Kinesis
68
+ stream is configured for larger records, raise the limit with `aws_max_record_bytes`, for
69
+ example `aws_max_record_bytes: 10 * 1024 * 1024`.
70
+ * HTTP: records the collector rejects with 400, 413, 415 or 422. The rest of the batch is sent
71
+ one record at a time. Other error statuses, such as 401, 429 or 5xx, are retried.
72
+
57
73
  ## Sync Mode
58
74
 
59
75
  All logger outputs support a `sync` setting. This is analogous to the "sync mode" setting on Ruby IO
data/docs/outputs.md CHANGED
@@ -39,4 +39,27 @@ LogStashLogger.new \
39
39
  url: 'http://localhost:8080'
40
40
  ```
41
41
 
42
+ A batch that gets an error status is retried on the next flush. If the collector rejects the content
43
+ (400, 413, 415 or 422), the records are sent one at a time and only the rejected ones are dropped.
44
+ See [Records that can never be delivered](buffering.md#records-that-can-never-be-delivered).
45
+
42
46
  Note the parameter is `url` and not `uri`. Relies on [Net:HTTP](https://ruby-doc.org/stdlib-2.7.1/libdoc/net/http/rdoc/Net/HTTP.html#class-Net::HTTP-label-HTTPS) to auto-detect SSL usage from the scheme.
47
+
48
+ By default, each batch is sent as a JSON array, which is what the Logstash HTTP Input expects. For endpoints that expect newline-delimited JSON (NDJSON), with one event per line, set `format: :ndjson`:
49
+
50
+ ```ruby
51
+ LogStashLogger.new \
52
+ type: :http,
53
+ url: 'https://example.com:8071/?token=TOKEN',
54
+ format: :ndjson
55
+ ```
56
+
57
+ The `format` option accepts `:json_array` (the default) or `:ndjson`. With `:ndjson` the request is sent with `Content-Type: application/x-ndjson`; with `:json_array` it uses `application/json`. If your endpoint expects a different header, set it explicitly with `content_type`:
58
+
59
+ ```ruby
60
+ LogStashLogger.new \
61
+ type: :http,
62
+ url: 'https://example.com:8071/?token=TOKEN',
63
+ format: :ndjson,
64
+ content_type: 'application/json'
65
+ ```
data/docs/rails.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rails Integration
2
2
 
3
- Supports Rails 7.2, 8.0, and 8.1.
3
+ Supports Rails 8.0 and 8.1.
4
4
 
5
5
  By default, every Rails log message will be written to logstash in `LogStash::Event` JSON format.
6
6
 
@@ -74,10 +74,15 @@ config.logstash.port = 5228
74
74
  # Required
75
75
  config.logstash.type = :tcp
76
76
 
77
- # Optional, enables SSL
77
+ # Optional, enables SSL and verifies the server certificate against the system trust store
78
78
  config.logstash.ssl_enable = true
79
+
80
+ # Optional, trusts this certificate (for a self-signed or private-CA server) and enables SSL
81
+ # config.logstash.ssl_certificate = '/path/to/certificate.crt'
79
82
  ```
80
83
 
84
+ See [SSL Configuration](ssl.md) for other options.
85
+
81
86
  ### Unix Socket
82
87
 
83
88
  ```ruby
data/docs/ssl.md CHANGED
@@ -1,30 +1,57 @@
1
1
  # SSL Configuration
2
2
 
3
- If you are using TCP then there is the option of adding an SSL certificate to the options hash on initialize.
3
+ The TCP output can connect to Logstash over TLS. The server certificate is always
4
+ verified, and so is the hostname, unless you opt out explicitly.
5
+
6
+ Enable TLS and verify the server against the system trust store:
7
+
8
+ ```ruby
9
+ LogStashLogger.new(type: :tcp, host: 'logstash.example.com', port: 5228, ssl_enable: true)
10
+ ```
11
+
12
+ If your Logstash server uses a self-signed certificate or a private CA, pass that
13
+ certificate (or CA bundle) as `ssl_certificate`. It is trusted in addition to the
14
+ system trust store, and TLS is enabled implicitly:
4
15
 
5
16
  ```ruby
6
- LogStashLogger.new(type: :tcp, port: 5228, ssl_certificate: "/path/to/certificate.crt")
17
+ LogStashLogger.new(type: :tcp, host: 'logstash.example.com', port: 5228, ssl_certificate: "/path/to/certificate.crt")
7
18
  ```
8
19
 
9
- The SSL certificate and key can be generated using
20
+ The file is checked when the logger is created, and an `ArgumentError` is raised
21
+ if it is missing or is not a certificate.
22
+
23
+ A self-signed certificate and key for the Logstash server can be generated with
10
24
 
11
- openssl req -x509 -batch -nodes -newkey rsa:2048 -keyout logstash.key -out logstash.crt
25
+ openssl req -x509 -batch -nodes -newkey rsa:2048 -subj "/CN=logstash.example.com" \
26
+ -addext "subjectAltName=DNS:logstash.example.com" -keyout logstash.key -out logstash.crt
12
27
 
13
- You can also enable SSL without a certificate:
28
+ Use the name your clients connect to in the certificate.
29
+
30
+ Specify an SSL context for full control over the connection. For example, to
31
+ trust a private CA:
14
32
 
15
33
  ```ruby
16
- LogStashLogger.new(type: :tcp, port: 5228, ssl_enable: true)
34
+ ctx = OpenSSL::SSL::SSLContext.new
35
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, ca_file: '/path/to/ca.pem')
36
+ LogStashLogger.new(type: :tcp, host: 'logstash.example.com', port: 5228, ssl_context: ctx)
17
37
  ```
18
38
 
19
- Specify an SSL context to have more control over the behavior. For example,
20
- set the verify mode:
39
+ ### Turning verification off
40
+
41
+ Verification can only be turned off with an explicit context. This exposes the
42
+ log stream to anyone who can intercept the connection, so use it only for local
43
+ testing:
21
44
 
22
45
  ```ruby
23
46
  ctx = OpenSSL::SSL::SSLContext.new
24
- ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE)
25
- LogStashLogger.new(type: :tcp, port: 5228, ssl_context: ctx)
47
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
48
+ LogStashLogger.new(type: :tcp, host: 'localhost', port: 5228, ssl_context: ctx, verify_hostname: false)
26
49
  ```
27
50
 
51
+ Before 1.1.0, `ssl_enable: true` without an `ssl_context` did not verify the
52
+ server certificate. If you relied on that, either pass `ssl_certificate` with the
53
+ server's certificate, or use the explicit context above.
54
+
28
55
  ## Logstash Configuration for SSL
29
56
 
30
57
  The following Logstash configuration is required for SSL:
@@ -44,47 +71,32 @@ input {
44
71
 
45
72
  ## Hostname Verification
46
73
 
47
- Hostname verification is enabled by default. Without further configuration,
48
- the hostname supplied to `:host` will be used to verify the server's certificate
49
- identity.
50
-
51
- If you don't pass an `:ssl_context` or pass a falsey value to the
52
- `:verify_hostname` option, hostname verification will not occur.
74
+ Hostname verification is enabled by default whenever TLS is used. The hostname
75
+ supplied to `:host` is checked against the server's certificate, and it is also
76
+ sent as the TLS server name (SNI) unless it is an IP address.
53
77
 
54
- ### Examples
78
+ **Verify a hostname different from the `:host` option**
55
79
 
56
- **Verify the hostname from the `:host` option**
80
+ This is useful when connecting by IP address:
57
81
 
58
82
  ```ruby
59
- ctx = OpenSSL::SSL::SSLContext.new
60
- ctx.cert = '/path/to/cert.pem'
61
- ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
62
-
63
83
  LogStashLogger.new \
64
84
  type: :tcp,
65
- host: 'logstash.example.com'
85
+ host: '1.2.3.4',
66
86
  port: 5228,
67
- ssl_context: ctx
87
+ ssl_certificate: '/path/to/certificate.crt',
88
+ verify_hostname: 'logstash.example.com'
68
89
  ```
69
90
 
70
- **Verify a hostname different from the `:host` option**
71
-
72
- ```ruby
73
- LogStashLogger.new \
74
- type: :tcp,
75
- host: '1.2.3.4'
76
- port: 5228,
77
- ssl_context: ctx,
78
- verify_hostname: 'server.example.com'
79
- ```
91
+ **Disable hostname verification**
80
92
 
81
- **Explicitly disable hostname verification**
93
+ The certificate chain is still verified:
82
94
 
83
95
  ```ruby
84
96
  LogStashLogger.new \
85
97
  type: :tcp,
86
- host: '1.2.3.4'
98
+ host: '1.2.3.4',
87
99
  port: 5228,
88
- ssl_context: ctx,
100
+ ssl_certificate: '/path/to/certificate.crt',
89
101
  verify_hostname: false
90
102
  ```
@@ -15,6 +15,24 @@ Your application is probably attempting to log data that is not encoded in a val
15
15
  standard JSON library will raise an exception. You may be able to overcome this by swapping out a different JSON encoder
16
16
  such as Oj. Use the [oj_mimic_json](https://github.com/ohler55/oj_mimic_json) gem to use Oj for JSON generation.
17
17
 
18
+ ## Records tagged `_logstashlogger_format_failure`
19
+
20
+ When a log call cannot be turned into a record, for example because a logged
21
+ value cannot be serialized to JSON, LogStashLogger writes a placeholder record
22
+ instead. It is framed like any other record, so the records around it are not
23
+ affected. The placeholder has the message `Failed to format log event`, the
24
+ tag `_logstashlogger_format_failure`, the logger's time and severity, and the
25
+ error class in an `error` field. The error itself is reported to the error
26
+ logger. Before 1.1.0 a bare `Failed to format log event` string was written
27
+ without a line ending, which could merge with the next record.
28
+
29
+ ## The `cee_syslog` header shows the local hostname
30
+
31
+ The `cee_syslog` formatter writes a `hostname progname:@cee:` header before
32
+ each record. The hostname is always this machine's hostname, and control
33
+ characters in the header are replaced with spaces. A `host` field in the logged
34
+ content, or one set in `customize_event`, appears only in the JSON body.
35
+
18
36
  ## No logs getting sent on Heroku
19
37
 
20
38
  Heroku recommends installing the [rails_12factor](https://github.com/heroku/rails_12factor) so that logs get sent to STDOUT.
@@ -50,6 +68,10 @@ for other fields.
50
68
 
51
69
  # Breaking changes
52
70
 
71
+ ## Version 1.1+
72
+
73
+ MRI Ruby < 3.3 and Rails < 8.0 are no longer supported, since they have been EOL'ed. If you are on Ruby 3.2 or Rails 7.2, you will need to use 1.0.x.
74
+
53
75
  ## Version 0.27+
54
76
 
55
77
  MRI Ruby < 3.2 is no longer supported, since it has been EOL'ed. If you are on an older version of Ruby, you will need to use 0.26 or below.
@@ -2,7 +2,9 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "codeclimate-test-reporter", group: :test, require: nil
5
+ gem "simplecov", ">= 0.22", group: :test, require: false
6
+ gem "simplecov-cobertura", group: :test, require: false
6
7
  gem "rails", "~> 8.0.0"
8
+ gem "json", "< 3"
7
9
 
8
10
  gemspec path: "../"
@@ -2,7 +2,8 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "codeclimate-test-reporter", group: :test, require: nil
5
+ gem "simplecov", ">= 0.22", group: :test, require: false
6
+ gem "simplecov-cobertura", group: :test, require: false
6
7
  gem "rails", "~> 8.1.0"
7
8
 
8
9
  gemspec path: "../"
@@ -180,6 +180,16 @@ module LogStashLogger
180
180
  end
181
181
  end
182
182
 
183
+ # Put items back into the pending queue for the next flush, without
184
+ # applying the full-buffer policy. Safe to call from inside a flush.
185
+ def buffer_requeue(items, group=nil)
186
+ return if items.empty?
187
+ @buffer_state[:pending_mutex].synchronize do
188
+ @buffer_state[:pending_items][group].concat(items)
189
+ @buffer_state[:pending_count] += items.size
190
+ end
191
+ end
192
+
183
193
  # Try to flush events.
184
194
  #
185
195
  # Returns immediately if flushing is not necessary/possible at the moment:
@@ -305,11 +315,12 @@ module LogStashLogger
305
315
  end
306
316
  end
307
317
 
318
+ # Never kills the timer from inside itself: called on the timer thread,
319
+ # this keeps the running timer, and reset_buffer reuses it.
308
320
  def reset_flush_timer_thread
309
- unless @flush_timer_thread.nil?
310
- @flush_timer_thread.kill
311
- @flush_timer_thread = nil
312
- end
321
+ return if @flush_timer_thread.nil? || @flush_timer_thread == Thread.current
322
+ @flush_timer_thread.kill
323
+ @flush_timer_thread = nil
313
324
  end
314
325
 
315
326
  def buffer_clear_pending
@@ -18,6 +18,7 @@ module LogStashLogger
18
18
  end
19
19
 
20
20
  attr_accessor :aws_region, :stream
21
+ attr_reader :max_record_bytes
21
22
 
22
23
  def initialize(opts)
23
24
  super
@@ -25,6 +26,22 @@ module LogStashLogger
25
26
  @secret_access_key = opts[:aws_secret_access_key]
26
27
  @aws_region = opts[:aws_region]
27
28
  @stream = opts[:stream] || DEFAULT_STREAM
29
+ # The largest record the stream accepts. Kinesis streams can be
30
+ # configured above the 1 MiB default, up to 10 MiB.
31
+ @max_record_bytes = Integer(opts[:aws_max_record_bytes] || self.class.max_record_bytes)
32
+ end
33
+
34
+ # Service limits, defined by each subclass
35
+ def self.max_records
36
+ fail NotImplementedError
37
+ end
38
+
39
+ def self.max_record_bytes
40
+ fail NotImplementedError
41
+ end
42
+
43
+ def self.max_request_bytes
44
+ fail NotImplementedError
28
45
  end
29
46
 
30
47
  def transform_message(message)
@@ -55,27 +72,32 @@ module LogStashLogger
55
72
  yield
56
73
  rescue => e
57
74
  log_error(e)
58
- log_warning("giving up")
59
75
  close(flush: false)
76
+ raise
60
77
  end
61
78
 
62
79
  def write_batch(messages, _group = nil)
63
- records = messages.map{ |m| transform_message(m) }
64
-
65
- with_connection do
66
- resp = put_records(records)
67
-
68
- # Put any failed records back into the buffer
69
- if !is_successful_response(resp)
70
- get_response_records(resp).each_with_index do |record, index|
71
- if self.class.recoverable_error_codes.include?(record.error_code)
72
- log_warning("Failed to post record using #{self.class.stream_class.name} with error: #{record.error_code} #{record.error_message}")
73
- log_warning("Retrying")
74
- write(records[index][:data])
75
- elsif !record.error_code.nil? && record.error_code != ''
76
- log_error("Failed to post record using #{self.class.stream_class.name} with error: #{record.error_code} #{record.error_message}")
77
- end
78
- end
80
+ records = []
81
+ messages.each do |message|
82
+ record = transform_message(message.to_s)
83
+ size = record_size(record)
84
+ if size > max_record_bytes
85
+ log_warning("dropping a record of #{size} bytes: over the #{max_record_bytes}-byte limit of #{self.class.stream_class.name}")
86
+ else
87
+ records << record
88
+ end
89
+ end
90
+
91
+ slices = request_slices(records)
92
+ slices.each_with_index do |slice, index|
93
+ begin
94
+ with_connection { send_slice(slice) }
95
+ rescue StandardError
96
+ # Already logged by with_connection. Only records that were not
97
+ # sent go back; dropped oversized records never do.
98
+ unsent = slices[index..-1].flatten(1).map { |r| r[:data] }
99
+ give_back(unsent)
100
+ return
79
101
  end
80
102
  end
81
103
  end
@@ -88,6 +110,63 @@ module LogStashLogger
88
110
  @io = nil
89
111
  end
90
112
 
113
+ private
114
+
115
+ def send_slice(records)
116
+ resp = put_records(records)
117
+ return if is_successful_response(resp)
118
+
119
+ retries = []
120
+ retry_codes = []
121
+ get_response_records(resp).each_with_index do |record, index|
122
+ if self.class.recoverable_error_codes.include?(record.error_code)
123
+ retries << records[index][:data]
124
+ retry_codes << record.error_code
125
+ elsif !record.error_code.nil? && record.error_code != ''
126
+ log_warning("dropping a record that #{self.class.stream_class.name} rejected: #{record.error_code} #{record.error_message}")
127
+ end
128
+ end
129
+ return if retries.empty?
130
+
131
+ log_warning("#{retries.size} records will be retried on the next flush (#{retry_codes.tally.map { |c, n| "#{c}: #{n}" }.join(', ')})")
132
+ # This runs inside a flush, so requeue without the full-buffer check,
133
+ # which would count the batch being flushed against the limit.
134
+ buffer_requeue(retries, @buffer_group)
135
+ end
136
+
137
+ def record_size(record)
138
+ record[:data].bytesize + record.fetch(:partition_key, '').bytesize
139
+ end
140
+
141
+ # Groups records into requests that fit the service's record-count and
142
+ # request-size limits.
143
+ def request_slices(records)
144
+ slices = []
145
+ bytes = 0
146
+ records.each do |record|
147
+ size = record_size(record)
148
+ if slices.empty? || slices.last.size >= self.class.max_records || bytes + size > self.class.max_request_bytes
149
+ slices << []
150
+ bytes = 0
151
+ end
152
+ slices.last << record
153
+ bytes += size
154
+ end
155
+ slices
156
+ end
157
+
158
+ # Records from a request that failed: retried on the next flush, or
159
+ # dropped when drop_messages_on_flush_error is set.
160
+ def give_back(messages)
161
+ return if messages.empty?
162
+ if @drop_messages_on_flush_error
163
+ log_warning("dropping #{messages.size} records after a failed request")
164
+ else
165
+ log_warning("#{messages.size} records will be retried on the next flush")
166
+ buffer_requeue(messages, @buffer_group)
167
+ end
168
+ end
169
+
91
170
  end
92
171
  end
93
172
  end
@@ -36,6 +36,11 @@ module LogStashLogger
36
36
 
37
37
  @buffer_logger = opts[:buffer_logger]
38
38
 
39
+ # Optional cap on the size of a single formatted record. Off by default.
40
+ # Deleted from opts so outputs that forward their options (Redis) never
41
+ # receive it.
42
+ @max_record_bytes = parse_max_record_bytes(opts.delete(:max_record_bytes))
43
+
39
44
  buffer_initialize(
40
45
  max_items: @buffer_max_items,
41
46
  max_interval: @buffer_max_interval,
@@ -48,7 +53,15 @@ module LogStashLogger
48
53
  end
49
54
 
50
55
  def write(message)
51
- buffer_receive(message, @buffer_group) unless message.nil?
56
+ return if message.nil?
57
+ if @max_record_bytes
58
+ size = message.to_s.bytesize
59
+ if size > @max_record_bytes
60
+ log_warning("dropping a record of #{size} bytes: over max_record_bytes (#{@max_record_bytes})")
61
+ return
62
+ end
63
+ end
64
+ buffer_receive(message, @buffer_group)
52
65
  end
53
66
 
54
67
  def flush(*args)
@@ -109,6 +122,19 @@ module LogStashLogger
109
122
  connect
110
123
  end
111
124
 
125
+ private
126
+
127
+ def parse_max_record_bytes(value)
128
+ return nil if value.nil? || value.to_s.strip.empty?
129
+ bytes = Integer(value)
130
+ raise ArgumentError, "max_record_bytes must be a positive number of bytes: #{value.inspect}" unless bytes > 0
131
+ bytes
132
+ rescue TypeError
133
+ raise ArgumentError, "max_record_bytes must be a positive number of bytes: #{value.inspect}"
134
+ end
135
+
136
+ public
137
+
112
138
  # Ensure the block is executed with a valid connection
113
139
  def with_connection()
114
140
  connect unless connected?
@@ -16,6 +16,12 @@ module LogStashLogger
16
16
  "ServiceUnavailableException"
17
17
  ].freeze
18
18
 
19
+ # https://docs.aws.amazon.com/firehose/latest/APIReference/API_PutRecordBatch.html
20
+ # 1,000 KiB per record and 4 MB per request; these cannot be changed.
21
+ def self.max_records; 500; end
22
+ def self.max_record_bytes; 1_024_000; end
23
+ def self.max_request_bytes; 4_000_000; end
24
+
19
25
  def transform_message(message)
20
26
  {
21
27
  data: message