twiglet 3.14.2 → 3.15.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.
data/docs/index.md DELETED
@@ -1,256 +0,0 @@
1
- # Twiglet: Ruby version
2
- Like a log, only smaller.
3
-
4
- This library provides a minimal JSON logging interface suitable for use in (micro)services. See the [RATIONALE](docs/RATIONALE.md) for design rationale and an explantion of the Elastic Common Schema that we are using for log attribute naming.
5
-
6
- ## Installation
7
-
8
- ```bash
9
- gem install twiglet
10
- ```
11
-
12
- ## How to use
13
-
14
- ### Instantiate the logger
15
-
16
- ```ruby
17
- require 'twiglet/logger'
18
- logger = Twiglet::Logger.new('service name')
19
- ```
20
- #### Optional initialization parameters
21
- A hash can optionally be passed in as a keyword argument for `default_properties`. This hash must be in the Elastic Common Schema format and will be present in every log message created by this Twiglet logger object.
22
-
23
- You may also provide an optional `output` keyword argument which should be an object with a `puts` method - like `$stdout`.
24
-
25
- In addition, you can provide another optional keyword argument called `now`, which should be a function returning a `Time` string in ISO8601 format.
26
-
27
- Lastly, you may provide the optional keyword argument `level` to initialize the logger with a severity threshold. Alternatively, the threshold can be updated at runtime by calling the `level` instance method.
28
-
29
- The defaults for both `output` and `now` should serve for most uses, though you may want to override them for testing as we have done [here](test/logger_test.rb).
30
-
31
- ### Invoke the Logger
32
-
33
- ```ruby
34
- logger.error({ event: { action: 'startup' }, message: "Emergency! There's an Emergency going on" })
35
- ```
36
-
37
- This will write to STDOUT a JSON string:
38
-
39
- ```json
40
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:54:59.164+01:00","log":{"level":"error"},"event":{"action":"startup"},"message":"Emergency! There's an Emergency going on"}
41
- ```
42
-
43
- Obviously the timestamp will be different.
44
-
45
- Alternatively, if you just want to log some error string:
46
-
47
- ```ruby
48
- logger.error("Emergency! There's an Emergency going on")
49
- ```
50
-
51
- This will write to STDOUT a JSON string:
52
-
53
- ```json
54
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:54:59.164+01:00","log":{"level":"error"}, "message":"Emergency! There's an Emergency going on"}
55
- ```
56
-
57
- A message is always required unless a block is provided. The message can be an object or a string.
58
-
59
- #### Error logging
60
- An optional error can also be provided, in which case the error message and backtrace will be logged in the relevant ECS compliant fields:
61
-
62
- ```ruby
63
- db_err = StandardError.new('Connection timed-out')
64
- logger.error({ message: 'DB connection failed.' }, db_err)
65
-
66
- # this is also valid
67
- logger.error('DB connection failed.', db_err)
68
- ```
69
-
70
- These will both result in the same JSON string written to STDOUT:
71
-
72
- ```json
73
- {"ecs":{"version":"1.5.0"},"@timestamp":"2020-08-21T15:44:37.890Z","service":{"name":"service name"},"log":{"level":"error"},"message":"DB connection failed.","error":{"message":"Connection timed-out"}}
74
- ```
75
-
76
- #### Custom fields
77
- Log custom event-specific information simply as attributes in a hash:
78
-
79
- ```ruby
80
- logger.info({
81
- event: { action: 'HTTP request' },
82
- message: 'GET /pets success',
83
- trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' },
84
- http: {
85
- request: { method: 'get' },
86
- response: { status_code: 200 }
87
- },
88
- url: { path: '/pets' }
89
- })
90
- ```
91
-
92
- This writes:
93
-
94
- ```json
95
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:56:49.527+01:00","log":{"level":"info"},"event":{"action":"HTTP request"},"message":"GET /pets success","trace":{"id":"1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb"},"http":{"request":{"method":"get"},"response":{"status_code":200}},"url":{"path":"/pets"}}
96
- ```
97
-
98
- Similar to error you can use string logging here as:
99
-
100
- ```
101
- logger.info('GET /pets success')
102
- ```
103
-
104
- This writes:
105
-
106
- ```json
107
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:56:49.527+01:00","log":{"level":"info"}}
108
- ```
109
-
110
- It may be that when making a series of logs that write information about a single event, you may want to avoid duplication by creating an event specific logger that includes the context:
111
-
112
- ```ruby
113
- request_logger = logger.with({ event: { action: 'HTTP request'}, trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' }})
114
- ```
115
-
116
- This can be used like any other Logger instance:
117
-
118
- ```ruby
119
- request_logger.error({
120
- message: 'Error 500 in /pets/buy',
121
- http: {
122
- request: { method: 'post', 'url.path': '/pet/buy' },
123
- response: { status_code: 500 }
124
- }
125
- })
126
- ```
127
-
128
- which will print:
129
-
130
- ```json
131
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:58:30.780+01:00","log":{"level":"error"},"event":{"action":"HTTP request"},"trace":{"id":"126bb6fa-28a2-470f-b013-eefbf9182b2d"},"message":"Error 500 in /pets/buy","http":{"request":{"method":"post","url.path":"/pet/buy"},"response":{"status_code":500}}}
132
- ```
133
-
134
- ### Log formatting
135
- Some third party applications will allow you to optionally specify a [log formatter](https://ruby-doc.org/stdlib-2.4.0/libdoc/logger/rdoc/Logger/Formatter.html).
136
- Supplying a Twiglet log formatter will format those third party logs so that they are ECS compliant and have the same default parameters as your application's internal logs.
137
-
138
- To access the formatter:
139
- ```ruby
140
- logger.formatter
141
- ```
142
-
143
- ### HTTP Request Logging
144
- Take a look at this sample [Rack application](examples/rack/example_rack_app.rb#L15) with an ECS compliant
145
- [request logger](/examples/rack/request_logger.rb) as a template when configuring your own request logging middleware with Twiglet.
146
-
147
- ### Log format validation
148
- Twiglet allows for the configuration of a custom validation schema. The validation schema must be [JSON Schema](https://json-schema.org/) compliant. Any fields not explicitly included in the provided schema are permitted by default.
149
-
150
- For example, given the following JSON Schema:
151
- ```ruby
152
- validation_schema = <<-JSON
153
- {
154
- "type": "object",
155
- "required": ["pet"],
156
- "properties": {
157
- "pet": {
158
- "type": "object",
159
- "required": ["name", "best_boy_or_girl?"],
160
- "properties": {
161
- "name": {
162
- "type": "string",
163
- "minLength": 1
164
- },
165
- "good_boy?": {
166
- "type": "boolean"
167
- }
168
- }
169
- }
170
- }
171
- }
172
- JSON
173
- ```
174
-
175
- The logger can be instantiated with the custom schema
176
- ```ruby
177
- custom_logger = Twiglet::Logger.new('service name', validation_schema: validation_schema)
178
- ```
179
-
180
- Compliant log messages will log as normal.
181
- ```ruby
182
- # this is compliant
183
- custom_logger.debug(pet: { name: 'Davis', good_boy?: true })
184
-
185
- # the result
186
- {:ecs=>{:version=>"1.5.0"}, :@timestamp=>"2020-05-11T15:01:01.000Z", :service=>{:name=>"petshop"}, :log=>{:level=>"debug"}, :pet=>{:name=>"Davis", :good_boy?=>true}}
187
- ```
188
-
189
- Non compliant messages will raise an error.
190
- ```ruby
191
- begin
192
- custom_logger.debug(pet: { name: 'Davis' })
193
- rescue JSON::Schema::ValidationError
194
- # we forgot to specify that he's a good boy!
195
- puts 'uh-oh'
196
- end
197
- ```
198
-
199
- #### Customizing error responses
200
- Depending on the application, it may not be desirable for the logger to raise Runtime errors. Twiglet allows you to configure a custom response for handling validation errors.
201
-
202
- Configure error handling by writing a block
203
- ```ruby
204
- logger.configure_validation_error_response do |error|
205
- # validation error handling goes here
206
- # for example:
207
- {YOUR APPLICATION BUG TRACKING SERVICE}.notify_error(error)
208
- end
209
-
210
- ```
211
-
212
- ### Use of dotted keys (DEPRECATED)
213
-
214
- Writing nested json objects could be confusing. This library has a built-in feature to convert dotted keys into nested objects, so if you log like this:
215
-
216
- ```ruby
217
- logger.info({
218
- 'event.action': 'HTTP request',
219
- message: 'GET /pets success',
220
- 'trace.id': '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
221
- 'http.request.method': 'get',
222
- 'http.response.status_code': 200,
223
- 'url.path': '/pets'
224
- })
225
- ```
226
-
227
- or mix between dotted keys and nested objects:
228
-
229
- ```ruby
230
- logger.info({
231
- 'event.action': 'HTTP request',
232
- message: 'GET /pets success',
233
- trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' },
234
- 'http.request.method': 'get',
235
- 'http.response.status_code': 200,
236
- url: { path: '/pets' }
237
- })
238
- ```
239
-
240
- Both cases would print out exact the same log item:
241
-
242
- ```json
243
- {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:59:31.183+01:00","log":{"level":"info"},"event":{"action":"HTTP request"},"message":"GET /pets success","trace":{"id":"1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb"},"http":{"request":{"method":"get"},"response":{"status_code":200}},"url":{"path":"/pets"}}
244
- ```
245
-
246
- ## How to contribute
247
-
248
- First: Please read our project [Code of Conduct](../CODE_OF_CONDUCT.md).
249
-
250
- Second: run the tests and make sure your changes don't break anything:
251
-
252
- ```bash
253
- bundle exec rake test
254
- ```
255
-
256
- Then please feel free to submit a PR.
data/example_app.rb DELETED
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/twiglet/logger'
4
-
5
- PORT = 8080
6
-
7
- logger = Twiglet::Logger.new('petshop')
8
-
9
- # Start our petshop
10
- logger.info(
11
- {
12
- event: {
13
- action: 'startup'
14
- },
15
- message: "Ready to go, listening on port #{PORT}",
16
- server: {
17
- port: PORT
18
- }
19
- }
20
- )
21
-
22
- # Use text logging
23
- logger.info("Ready to go, listening on port #{PORT}")
24
- #
25
- # We get a request
26
- request_logger = logger.with(
27
- {
28
- event: {
29
- action: 'HTTP request'
30
- },
31
- trace: {
32
- id: '126bb6fa-28a2-470f-b013-eefbf9182b2d'
33
- }
34
- }
35
- )
36
-
37
- # Oh noes!
38
- db_err = StandardError.new('Connection timed-out')
39
-
40
- request_logger.error({ message: 'DB connection failed.' }, db_err) if db_err
41
-
42
- # We return an error to the requester
43
- request_logger.info(
44
- {
45
- message: 'Internal Server Error',
46
- http: {
47
- request: {
48
- method: 'get'
49
- },
50
- response: {
51
- status_code: 500
52
- }
53
- }
54
- }
55
- )
56
-
57
- # Logging with an empty message is an anti-pattern and is therefore forbidden
58
- # Both of the following lines would throw an error
59
- # request_logger.error({ message: "" })
60
- # logger.debug({ message: " " })
@@ -1,17 +0,0 @@
1
- require 'twiglet/logger'
2
- require 'request_logger'
3
-
4
- # basic rack application
5
- class Application
6
- def call(_env)
7
- status = 200
8
- headers = { "Content-Type" => "text/json" }
9
- body = ["Example rack app"]
10
-
11
- [status, headers, body]
12
- end
13
- end
14
-
15
- use RequestLogger, Twiglet::Logger.new('example_app')
16
-
17
- run Application.new
@@ -1,66 +0,0 @@
1
- # Middleware for logging request logs
2
- class RequestLogger
3
- def initialize(app, logger)
4
- @app = app
5
- @logger = logger
6
- end
7
-
8
- def call(env)
9
- status, headers, body = @app.call(env)
10
- log(env, status)
11
- [status, headers, body]
12
- rescue StandardError => e
13
- log_error(env, 500, e)
14
- raise e
15
- end
16
-
17
- private
18
-
19
- def log(env, status)
20
- fields = get_fields(env, status)
21
- @logger.info(fields)
22
- end
23
-
24
- def log_error(env, status, error)
25
- fields = get_fields(env, status)
26
- @logger.error(fields, error)
27
- end
28
-
29
- # https://www.elastic.co/guide/en/ecs/1.5/ecs-field-reference.html
30
- def get_fields(env, status)
31
- message = "#{env['REQUEST_METHOD']}: #{env['PATH_INFO']}"
32
-
33
- {
34
- http: http_fields(env, status),
35
- url: url_fields(env),
36
- client: {
37
- ip: env['HTTP_TRUE_CLIENT_IP'] || env['REMOTE_ADDR']
38
- },
39
- user_agent: {
40
- original: env['HTTP_USER_AGENT']
41
- },
42
- message: message
43
- }
44
- end
45
-
46
- def http_fields(env, status)
47
- {
48
- request: {
49
- method: env['REQUEST_METHOD'],
50
- mime_type: env['HTTP_ACCEPT']
51
- },
52
- response: {
53
- status: status
54
- },
55
- version: env['HTTP_VERSION']
56
- }
57
- end
58
-
59
- def url_fields(env)
60
- {
61
- path: env['PATH_INFO'],
62
- query: env['QUERY_STRING'],
63
- domain: env['SERVER_NAME']
64
- }
65
- end
66
- end
@@ -1,91 +0,0 @@
1
- require 'minitest/autorun'
2
- require_relative '../../lib/twiglet/logger'
3
- require_relative './request_logger'
4
- require 'rack'
5
-
6
- describe RequestLogger do
7
- let(:output) { StringIO.new }
8
-
9
- before { output.rewind }
10
-
11
- it 'log should not be empty' do
12
- request.get("/some/path")
13
- log = output.string
14
- refute_empty log
15
- end
16
-
17
- it 'logs the request data' do
18
- request.get(
19
- "/some/path?some_var=1", 'HTTP_ACCEPT' => 'application/json',
20
- 'REMOTE_ADDR' => '0.0.0.0',
21
- 'HTTP_VERSION' => 'HTTP/1.1',
22
- 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh)'
23
- )
24
- log = JSON.parse(output.string)
25
-
26
- expected_log = {
27
- "log" => { "level" => "info" },
28
- "http" => {
29
- "request" => {
30
- "method" => "GET",
31
- "mime_type" => 'application/json'
32
- },
33
- "response" => {
34
- "status" => 200
35
- },
36
- "version" => 'HTTP/1.1'
37
- },
38
- "url" => {
39
- "path" => "/some/path",
40
- "query" => "some_var=1",
41
- "domain" => "example.org"
42
- },
43
- "client" => {
44
- 'ip' => '0.0.0.0'
45
- },
46
- "user_agent" => {
47
- "original" => 'Mozilla/5.0 (Macintosh)'
48
- },
49
- "message" => "GET: /some/path"
50
- }
51
-
52
- assert_equal(log['log'], expected_log['log'])
53
- assert_equal(log['http'], expected_log['http'])
54
- assert_equal(log['url'], expected_log['url'])
55
- assert_equal(log['user_agent'], expected_log['user_agent'])
56
- assert_equal(log['message'], expected_log['message'])
57
- end
58
-
59
- it 'does not log PII' do
60
- request.post("/user/info", input_data: { credit_card_no: '1234' })
61
- log = output.string
62
- assert_includes log, "POST: /user/info"
63
- refute_includes log, 'credit_card_no'
64
- refute_includes log, '1234'
65
- end
66
-
67
- it 'logs an error message when a request is bad' do
68
- expect { bad_request.get("/some/path") }.must_raise StandardError
69
- log = JSON.parse(output.string)
70
- assert_equal log['log']['level'], 'error'
71
- assert_equal log['error']['message'], 'some exception'
72
- assert_equal log['error']['type'], 'StandardError'
73
- assert_includes log['error']['stack_trace'].first, 'examples/rack/request_logger_test.rb'
74
- end
75
- end
76
-
77
- def request
78
- app = ->(env) { [200, env, "app"] }
79
- base_request(app)
80
- end
81
-
82
- def bad_request
83
- app = Rack::Lint.new ->(_env) { raise StandardError, 'some exception' }
84
- base_request(app)
85
- end
86
-
87
- def base_request(app)
88
- logger = Twiglet::Logger.new('example', output: output)
89
- req_logger = RequestLogger.new(app, logger)
90
- Rack::MockRequest.new(req_logger)
91
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'minitest/autorun'
4
- require 'minitest/mock'
5
- require_relative '../lib/twiglet/error_serialiser'
6
-
7
- describe Twiglet::ErrorSerialiser do
8
- describe 'logging an exception' do
9
- it 'should log an error with backtrace' do
10
- 1 / 0
11
- rescue StandardError => e
12
- error_hash = Twiglet::ErrorSerialiser.new.serialise_error(e)
13
- assert_equal 'divided by 0', error_hash[:error][:message]
14
- assert_equal 'ZeroDivisionError', error_hash[:error][:type]
15
- assert_match 'test/error_serialiser_test.rb', error_hash[:error][:stack_trace].first
16
- end
17
- end
18
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'minitest/autorun'
4
- require 'json'
5
- require_relative '../lib/twiglet/formatter'
6
- require_relative '../lib/twiglet/validator'
7
-
8
- describe Twiglet::Formatter do
9
- before do
10
- @now = -> { Time.utc(2020, 5, 11, 15, 1, 1) }
11
- @formatter = Twiglet::Formatter.new('petshop', now: @now, validator: Twiglet::Validator.new({}.to_json))
12
- end
13
-
14
- it 'initializes an instance of a Ruby Logger Formatter' do
15
- assert @formatter.is_a?(::Logger::Formatter)
16
- end
17
-
18
- it 'returns a formatted log from a string message' do
19
- msg = @formatter.call('warn', nil, nil, 'shop is running low on dog food')
20
- expected_log = {
21
- "ecs" => {
22
- "version" => '1.5.0'
23
- },
24
- "@timestamp" => '2020-05-11T15:01:01.000Z',
25
- "service" => {
26
- "name" => 'petshop'
27
- },
28
- "log" => {
29
- "level" => 'warn'
30
- },
31
- "message" => 'shop is running low on dog food'
32
- }
33
- assert_equal JSON.parse(msg), expected_log
34
- end
35
-
36
- it 'merges the outputs of the context provider into messages logs' do
37
- provider = -> { { 'request' => { 'id' => '1234567890' } } }
38
- formatter = Twiglet::Formatter.new(
39
- 'petshop', now: @now, validator: Twiglet::Validator.new({}.to_json),
40
- context_provider: provider
41
- )
42
- msg = formatter.call('warn', nil, nil, 'shop is running low on dog food')
43
- expected_log = {
44
- "ecs" => {
45
- "version" => '1.5.0'
46
- },
47
- "@timestamp" => '2020-05-11T15:01:01.000Z',
48
- "service" => {
49
- "name" => 'petshop'
50
- },
51
- "log" => {
52
- "level" => 'warn'
53
- },
54
- "message" => 'shop is running low on dog food',
55
- "request" => {
56
- 'id' => '1234567890'
57
- }
58
- }
59
- assert_equal JSON.parse(msg), expected_log
60
- end
61
-
62
- it 'merges the outputs of all context providers into the messages log' do
63
- provider_1 = -> { { 'request' => { 'id' => '1234567890' } } }
64
- provider_2 = -> { { 'request' => { 'type' => 'test' } } }
65
- formatter = Twiglet::Formatter.new(
66
- 'petshop', now: @now, validator: Twiglet::Validator.new({}.to_json),
67
- context_providers: [provider_1, provider_2]
68
- )
69
- msg = formatter.call('warn', nil, nil, 'shop is running low on dog food')
70
- expected_log = {
71
- "ecs" => {
72
- "version" => '1.5.0'
73
- },
74
- "@timestamp" => '2020-05-11T15:01:01.000Z',
75
- "service" => {
76
- "name" => 'petshop'
77
- },
78
- "log" => {
79
- "level" => 'warn'
80
- },
81
- "message" => 'shop is running low on dog food',
82
- "request" => {
83
- 'id' => '1234567890',
84
- 'type' => 'test'
85
- }
86
- }
87
- assert_equal expected_log, JSON.parse(msg)
88
- end
89
- end