twiglet 1.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8780baf991dc54df9731f5cb2f02cbf1472c4f026d4b5a0604fe332b96f84384
4
- data.tar.gz: f059d2eafbb0ac5b5ba74c0f430508ca3966326ec832fb4b56df3efc3b436faf
3
+ metadata.gz: 356d8c7fec6c41823029cc36bef1cc49bacae6157342b251e9b57f49dcb8151f
4
+ data.tar.gz: bd58a67b48daa9bf6108dc78098f46ad0204f98ca600c29bacd713f6cb54ea2e
5
5
  SHA512:
6
- metadata.gz: 1d133f5b631a3a192f1dd7d7f54fecb90c481d593a747ee1ce2b9772eb91c6209af78efb0ec1ebfebe06dabbc76946029edc6a70e6c6493f2351be933ffae7e4
7
- data.tar.gz: 343cb5284055f7c5172f7451bbdeb73d1fd9f6c9f53c7341a44d997a63ffbc2f90aecece369e63e27b5955d45aa570f787d83e0c5d83cc52267e7329f9073c02
6
+ metadata.gz: 4bc443482b93b1bba853fbe67605d9b0673830726cf1ef89e941860bc7f832d1efb5f82d5890197ae279b9edf97bf19edf8dc2d8bf61d56d082d6e641f94a245
7
+ data.tar.gz: 8d5e178fca01957147dca807aab4117958b18514d568440e72f53cfc8931aad62035a71633646776368f4409d9be48ab8598c66aad4347edd59f79a0bfc281ae
@@ -1,4 +1,3 @@
1
1
  # Add your project owners info here
2
2
  # More information: https://help.github.com/articles/about-codeowners/
3
3
  * @simplybusiness/application-tooling
4
- *
@@ -28,5 +28,5 @@ jobs:
28
28
  - name: Rubocop Check
29
29
  run: bundle exec rubocop
30
30
  - name: Run all tests
31
- run: bundle exec ruby test/*
31
+ run: bundle exec rake test
32
32
  shell: bash
@@ -8,7 +8,7 @@ AllCops:
8
8
  Documentation:
9
9
  Enabled: false
10
10
  Metrics/BlockLength:
11
- Max: 200
11
+ Max: 250
12
12
  Metrics/AbcSize:
13
13
  Max: 20
14
14
  Metrics/MethodLength:
@@ -0,0 +1 @@
1
+ 2.6.5
data/Gemfile CHANGED
@@ -4,4 +4,5 @@ source 'https://rubygems.org'
4
4
  gem 'simplycop', git: 'https://github.com/simplybusiness/simplycop.git'
5
5
  group :development, :test do
6
6
  gem 'minitest'
7
+ gem 'rake'
7
8
  end
@@ -27,6 +27,7 @@ GEM
27
27
  ast (~> 2.4.0)
28
28
  rack (2.2.2)
29
29
  rainbow (3.0.0)
30
+ rake (13.0.1)
30
31
  rexml (3.2.4)
31
32
  rubocop (0.80.1)
32
33
  jaro_winkler (~> 1.5.1)
@@ -54,6 +55,7 @@ PLATFORMS
54
55
 
55
56
  DEPENDENCIES
56
57
  minitest
58
+ rake
57
59
  simplycop!
58
60
 
59
61
  BUNDLED WITH
@@ -0,0 +1,90 @@
1
+ # Twiglet
2
+
3
+ Why Twiglet? Because it's a log, only micro-sized.
4
+
5
+ This is a machine-readable first, human-readable second, JSON-based logging micro-library suitable for a wide variety of microservice uses.
6
+
7
+ This logging library is available in a cornucopia of languages:
8
+ * Ruby - here
9
+ * Python - TBC
10
+ * Node.js - [twiglet-node](https://github.com/simplybusiness/twiglet-node/)
11
+
12
+ ## Design considerations
13
+
14
+ The design goals of this library are:
15
+
16
+ - Write logs as JSON
17
+ - One line per log entry
18
+ - One JSON object per log entry
19
+ - Each log entry contains a severity[1]
20
+ - Each log entry contains an ISO8601 UTC timestamp
21
+ - Each log entry contains the name of the service that wrote it
22
+ - Each log entry pertains to an 'event'[2]
23
+ - Each log entry either a) propagates an existing trace.id or b) creates a new trace.id as a correlation ID if one does not already exist[3]
24
+ - Stack traces are written inside a single JSON log object rather than sprawling over several lines (TODO:)
25
+ - Personally Identifiable Information. Don't log it.
26
+
27
+ [1] It turns out that there isn't a single authoritative standard for severity levels, we chose to go with DEBUG, INFO, WARNING, ERROR and CRITICAL as our choices.
28
+ [2] The ‘event’ here merely refers to the action that the customer (or employee, service, or other actor) is currently attempting. It does not refer specifically to Kafka, or CQRS, though that might be the case.
29
+ [3] A correlation ID is a UUIDv4 string
30
+
31
+ ## Elastic Common Schema (ECS)
32
+ https://www.elastic.co/blog/introducing-the-elastic-common-schema
33
+ We have decided to standardise on the Elastic Common Schema for log attribute names. Whilst some attributes are expected for all logs, service owners should feel free to add relevant entries from the ECS schema if they are needed.
34
+ All application specific information is embedded in the `message` attribute JSON payload.
35
+
36
+ | Attribute name (mandatory) | Description |
37
+ | -------------------------- | ------------------------------- |
38
+ | log.level | text, one of DEBUG, INFO, WARNING, ERROR or CRITICAL. |
39
+ | service.name | text, the name of the service |
40
+ | @timestamp | text, ISO8601 UTC |
41
+ | message | text, human-readable summary |
42
+
43
+ | Attribute name (optional) | Description |
44
+ | -------------------------- | ------------------------------- |
45
+ | error.stack_trace | Stack trace, as JSON[4] |
46
+ | tags | Array, e.g. ["production"] |
47
+ | trace.id | text, UUIDv4 - a correlation ID |
48
+ | (other examples) | ... |
49
+
50
+ [4] Helper method to be provided to allow stack trace objects to be represented cleanly as JSON.
51
+
52
+ Errors should provide appropriate data using the fields from https://www.elastic.co/guide/en/ecs/current/ecs-error.html
53
+ If any other fields are provided in a log then these should be from the ECS schema rather than in a custom format, if at all possible.
54
+
55
+ ## Example log output (prettified)
56
+ ```json
57
+ {
58
+ "log": {
59
+ "level": "INFO"
60
+ },
61
+ "service": {
62
+ "name": "payments"
63
+ },
64
+ "@timestamp": "2020-05-07T11:51:33.976Z",
65
+ "event": {
66
+ "action": "customer-payment-accepted"
67
+ },
68
+ "trace": {
69
+ "id": "bf6f5ea3-614b-42f5-8e73-43deea2d1838"
70
+ },
71
+ "tags": ["staging"],
72
+ "message": "Pet cat Spot purchased",
73
+ "user": {
74
+ "email": "sleepyfox@gmail.com"
75
+ },
76
+ "pet": {
77
+ "type": "cat",
78
+ "name": "Spot",
79
+ "colour": "Ginger Tabby"
80
+ }
81
+ }
82
+ ```
83
+
84
+ # Code of conduct
85
+
86
+ Please see the [code of conduct](CODE_OF_CONDUCT.md) for further info.
87
+
88
+ # License
89
+
90
+ This work is licensed under the MIT license - see the [LICENSE](LICENSE) file for further details.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Twiglet: Ruby version
2
2
  Like a log, only smaller.
3
3
 
4
- This library provides a minimal JSON logging interface suitable for use in (micro)services. See the [README](../README.md) for design rationale and an explantion of the Elastic Common Schema that we are using for log attribute naming.
4
+ This library provides a minimal JSON logging interface suitable for use in (micro)services. See the [RATIONALE](RATIONALE.md) for design rationale and an explantion of the Elastic Common Schema that we are using for log attribute naming.
5
5
 
6
6
  ## Installation
7
7
 
@@ -15,10 +15,16 @@ Create a new logger like so:
15
15
 
16
16
  ```ruby
17
17
  require 'twiglet/logger'
18
- logger = Twiglet::Logger.new(conf: { service: 'petshop' })
18
+ logger = Twiglet::Logger.new('service name')
19
19
  ```
20
20
 
21
- The logger may be passed in the configuration object an optional `output` attribute which should be an object with a `puts` method - like `$stdout`. The configuration object may also have an optional `now` attribute, which should be a function returning a `Time` object. The defaults should serve for most uses, though you may want to override them for testing as we have done [here](test/logger_test.rb).
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
+ Lastly, you can provide another optional keyword argument called `now`, which should be a function returning a `Time` string in ISO8601 format.
26
+
27
+ 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).
22
28
 
23
29
  To use, simply invoke like most other loggers:
24
30
 
@@ -29,11 +35,29 @@ logger.error({ event: { action: 'startup' }, message: "Emergency! There's an Eme
29
35
  This will write to STDOUT a JSON string:
30
36
 
31
37
  ```json
32
- {"service":{"name":"petshop"},"@timestamp":"2020-05-14T10:54:59.164+01:00","log":{"level":"error"},"event":{"action":"startup"},"message":"Emergency! There's an Emergency going on"}
38
+ {"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"}
33
39
  ```
34
40
 
35
41
  Obviously the timestamp will be different.
36
42
 
43
+ Alternatively, if you just want to log some error message in text format
44
+ ```ruby
45
+ logger.error( "Emergency! There's an Emergency going on")
46
+ ```
47
+
48
+ This will write to STDOUT a JSON string:
49
+
50
+ ```json
51
+ {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:54:59.164+01:00","log":{"level":"error"}, "message":"Emergency! There's an Emergency going on"}
52
+ ```
53
+
54
+ Errors can be logged as well, and this will log the error message and backtrace in the relevant ECS compliant fields:
55
+
56
+ ```ruby
57
+ db_err = StandardError.new('Connection timed-out')
58
+ logger.error({ message: 'DB connection failed.' }, db_err)
59
+ ```
60
+
37
61
  Add log event specific information simply as attributes in a hash:
38
62
 
39
63
  ```ruby
@@ -52,9 +76,21 @@ logger.info({
52
76
  This writes:
53
77
 
54
78
  ```json
55
- {"service":{"name":"petshop"},"@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"}}
79
+ {"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"}}
80
+ ```
81
+
82
+ Similar to error you can use text logging here as:
83
+
84
+ ```
85
+ logger.info('GET /pets success')
86
+ ```
87
+ This writes:
88
+
89
+ ```json
90
+ {"service":{"name":"service name"},"@timestamp":"2020-05-14T10:56:49.527+01:00","log":{"level":"info"}}
56
91
  ```
57
92
 
93
+
58
94
  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:
59
95
 
60
96
  ```ruby
@@ -76,7 +112,7 @@ request_logger.error({
76
112
  which will print:
77
113
 
78
114
  ```json
79
- {"service":{"name":"petshop"},"@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}}}
115
+ {"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}}}
80
116
  ```
81
117
 
82
118
  ## Use of dotted keys
@@ -110,7 +146,7 @@ logger.info({
110
146
  Both cases would print out exact the same log item:
111
147
 
112
148
  ```json
113
- {"service":{"name":"petshop"},"@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"}}
149
+ {"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"}}
114
150
  ```
115
151
 
116
152
  ## How to contribute
@@ -120,7 +156,7 @@ First: Please read our project [Code of Conduct](../CODE_OF_CONDUCT.md).
120
156
  Second: run the tests and make sure your changes don't break anything:
121
157
 
122
158
  ```bash
123
- for file in test/*test.rb; do ruby $file; done
159
+ bundle exec rake test
124
160
  ```
125
161
 
126
162
  Then please feel free to submit a PR.
@@ -0,0 +1,7 @@
1
+ require 'rake/testtask'
2
+
3
+ Rake::TestTask.new do |t|
4
+ t.libs << "test"
5
+ t.test_files = FileList['test/*_test.rb']
6
+ t.verbose = true
7
+ end
@@ -4,7 +4,7 @@ require_relative 'lib/twiglet/logger'
4
4
 
5
5
  PORT = 8080
6
6
 
7
- logger = Logger.new(conf: { service: 'petshop' })
7
+ logger = Twiglet::Logger.new('petshop')
8
8
 
9
9
  # Start our petshop
10
10
  logger.info({
@@ -17,6 +17,9 @@ logger.info({
17
17
  }
18
18
  })
19
19
 
20
+ # Use text logging
21
+ logger.info("Ready to go, listening on port #{PORT}")
22
+ #
20
23
  # We get a request
21
24
  request_logger = logger.with({
22
25
  event: {
@@ -45,7 +48,7 @@ request_logger.info({
45
48
  }
46
49
  })
47
50
 
48
- # Logging with a non-empty message is an anti-pattern and is therefore forbidden
51
+ # Logging with an empty message is an anti-pattern and is therefore forbidden
49
52
  # Both of the following lines would throw an error
50
53
  # request_logger.error({ message: "" })
51
54
  # logger.debug({ message: " " })
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HashExtensions
4
+ def to_nested
5
+ self unless contains_dotted_key?
6
+
7
+ keys.reduce({}) do |nested, key|
8
+ nested.deep_merge(build_nested_object(key, self[key]))
9
+ end
10
+ end
11
+
12
+ def deep_merge(hash_to_merge)
13
+ merger = proc { |_, val1, val2| val1.is_a?(Hash) && val2.is_a?(Hash) ? val1.merge(val2, &merger) : val2 }
14
+ merge(hash_to_merge, &merger)
15
+ end
16
+
17
+ private
18
+
19
+ def contains_dotted_key?
20
+ keys.any? { |x| x.to_s.include?('.') }
21
+ end
22
+
23
+ def build_nested_object(key, val)
24
+ key.to_s
25
+ .split('.')
26
+ .reverse
27
+ .reduce(val) { |nested, key_part| Hash[key_part.to_sym, nested] }
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ require 'logger'
2
+ require_relative '../hash_extensions'
3
+
4
+ module Twiglet
5
+ class Formatter < ::Logger::Formatter
6
+ Hash.include HashExtensions
7
+
8
+ def initialize(service_name,
9
+ default_properties: {},
10
+ now: -> { Time.now.utc })
11
+ @service_name = service_name
12
+ @now = now
13
+ @default_properties = default_properties
14
+
15
+ super()
16
+ end
17
+
18
+ def call(severity, _time, _progname, msg)
19
+ level = severity.downcase
20
+ log(level: level, message: msg)
21
+ end
22
+
23
+ private
24
+
25
+ def log(level:, message:)
26
+ case message
27
+ when String
28
+ log_text(level, message: message)
29
+ when Hash
30
+ log_object(level, message: message)
31
+ else
32
+ raise('Message must be String or Hash')
33
+ end
34
+ end
35
+
36
+ def log_text(level, message:)
37
+ raise('The \'message\' property of log object must not be empty') if message.strip.empty?
38
+
39
+ message = { message: message }
40
+ log_message(level, message: message)
41
+ end
42
+
43
+ def log_object(level, message:)
44
+ message = message.transform_keys(&:to_sym)
45
+ message.key?(:message) || raise('Log object must have a \'message\' property')
46
+ message[:message].strip.empty? && raise('The \'message\' property of log object must not be empty')
47
+
48
+ log_message(level, message: message)
49
+ end
50
+
51
+ def log_message(level, message:)
52
+ base_message = {
53
+ "@timestamp": @now.call.iso8601(3),
54
+ service: {
55
+ name: @service_name
56
+ },
57
+ log: {
58
+ level: level
59
+ }
60
+ }
61
+
62
+ base_message
63
+ .deep_merge(@default_properties.to_nested)
64
+ .deep_merge(message.to_nested)
65
+ .to_json
66
+ end
67
+ end
68
+ end
@@ -1,82 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require 'time'
4
5
  require 'json'
5
- require_relative '../elastic_common_schema'
6
+ require 'twiglet/formatter'
7
+ require_relative '../hash_extensions'
6
8
 
7
9
  module Twiglet
8
- class Logger
9
- include ElasticCommonSchema
10
+ class Logger < ::Logger
11
+ Hash.include HashExtensions
10
12
 
11
- def initialize(conf:, scoped_properties: {})
12
- @service = conf[:service]
13
- @now = conf[:now] || -> { Time.now.utc }
14
- @output = conf[:output] || $stdout
13
+ def initialize(
14
+ service_name,
15
+ default_properties: {},
16
+ now: -> { Time.now.utc },
17
+ output: $stdout
18
+ )
19
+ @service_name = service_name
20
+ @now = now
21
+ @output = output
15
22
 
16
- raise 'configuration must have a service name' \
17
- unless @service.is_a?(String) && !@service.strip.empty?
23
+ raise 'Service name is mandatory' \
24
+ unless service_name.is_a?(String) && !service_name.strip.empty?
18
25
 
19
- @scoped_properties = scoped_properties
26
+ formatter = Twiglet::Formatter.new(service_name, default_properties: default_properties, now: now)
27
+ super(output, formatter: formatter)
20
28
  end
21
29
 
22
- def debug(message)
23
- log(level: 'debug', message: message)
24
- end
25
-
26
- def info(message)
27
- log(level: 'info', message: message)
28
- end
29
-
30
- def warning(message)
31
- log(level: 'warning', message: message)
32
- end
33
-
34
- def error(message, error = nil)
30
+ def error(message = {}, error = nil, &block)
35
31
  if error
36
- message = message.merge({
37
- error_name: error.message,
38
- backtrace: error.backtrace
39
- })
32
+ error_fields = {
33
+ 'error': {
34
+ 'message': error.message
35
+ }
36
+ }
37
+ add_stack_trace(error_fields, error)
38
+ message.is_a?(Hash) ? message.merge!(error_fields) : error_fields.merge!(message: message)
40
39
  end
41
40
 
42
- log(level: 'error', message: message)
41
+ super(message, &block)
43
42
  end
44
43
 
45
- def critical(message)
46
- log(level: 'critical', message: message)
44
+ def with(default_properties)
45
+ Logger.new(@service_name,
46
+ default_properties: default_properties,
47
+ now: @now,
48
+ output: @output)
47
49
  end
48
50
 
49
- def with(scoped_properties)
50
- Logger.new(conf: { service: @service,
51
- now: @now,
52
- output: @output },
53
- scoped_properties: scoped_properties)
54
- end
51
+ alias_method :warning, :warn
52
+ alias_method :critical, :fatal
55
53
 
56
54
  private
57
55
 
58
- def log(level:, message:)
59
- raise 'Message must be a Hash' unless message.is_a?(Hash)
60
-
61
- message = message.transform_keys(&:to_sym)
62
- message.key?(:message) || raise('Log object must have a \'message\' property')
63
-
64
- message[:message].strip.empty? && raise('The \'message\' property of log object must not be empty')
65
-
66
- total_message = {
67
- service: {
68
- name: @service
69
- },
70
- "@timestamp": @now.call.iso8601(3),
71
- log: {
72
- level: level
73
- }
74
- }
75
- total_message = total_message.merge(@scoped_properties)
76
- .merge!(message)
77
- .then { |log_entry| to_nested(log_entry) }
78
-
79
- @output.puts total_message.to_json
56
+ def add_stack_trace(hash_to_add_to, error)
57
+ hash_to_add_to[:error][:stack_trace] = error.backtrace.join("\n") if error.backtrace
80
58
  end
81
59
  end
82
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Twiglet
4
- VERSION = '1.1.0'
4
+ VERSION = '2.2.0'
5
5
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'json'
5
+ require_relative '../lib/twiglet/formatter'
6
+
7
+ describe Twiglet::Formatter do
8
+ before do
9
+ @now = -> { Time.utc(2020, 5, 11, 15, 1, 1) }
10
+ @formatter = Twiglet::Formatter.new('petshop', now: @now)
11
+ end
12
+
13
+ it 'initializes an instance of a Ruby Logger Formatter' do
14
+ assert @formatter.is_a?(::Logger::Formatter)
15
+ end
16
+
17
+ it 'returns a formatted log from a string message' do
18
+ msg = @formatter.call('warn', nil, nil, 'shop is running low on dog food')
19
+ expected_log = {
20
+ "@timestamp" => '2020-05-11T15:01:01.000Z',
21
+ "service" => {
22
+ "name" => 'petshop'
23
+ },
24
+ "log" => {
25
+ "level" => 'warn'
26
+ },
27
+ "message" => 'shop is running low on dog food'
28
+ }
29
+ assert_equal JSON.parse(msg), expected_log
30
+ end
31
+ end
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'minitest/autorun'
4
- require_relative '../lib/elastic_common_schema'
4
+ require_relative '../lib/hash_extensions'
5
5
 
6
- describe ElasticCommonSchema do
6
+ describe HashExtensions do
7
7
  before do
8
- @ecs = Object.new
9
- @ecs.extend(ElasticCommonSchema)
8
+ Hash.include HashExtensions
10
9
  end
11
10
 
12
11
  it 'should retain an object without . in any keys' do
@@ -21,7 +20,7 @@ describe ElasticCommonSchema do
21
20
  "@timestamp": '2020-05-09T15:13:20.736Z'
22
21
  }
23
22
 
24
- expected = @ecs.to_nested(actual)
23
+ expected = actual.to_nested
25
24
  assert_equal actual, expected
26
25
  end
27
26
 
@@ -31,7 +30,7 @@ describe ElasticCommonSchema do
31
30
  "log.level": 'error'
32
31
  }
33
32
 
34
- nested = @ecs.to_nested(actual)
33
+ nested = actual.to_nested
35
34
 
36
35
  assert_equal 'petshop', nested[:service][:name]
37
36
  assert_equal 'error', nested[:log][:level]
@@ -45,7 +44,7 @@ describe ElasticCommonSchema do
45
44
  "log.level": 'error'
46
45
  }
47
46
 
48
- nested = @ecs.to_nested(actual)
47
+ nested = actual.to_nested
49
48
 
50
49
  assert_equal 'petshop', nested[:service][:name]
51
50
  assert_equal 'ps001', nested[:service][:id]
@@ -61,7 +60,7 @@ describe ElasticCommonSchema do
61
60
  "http.response.status_code": 200
62
61
  }
63
62
 
64
- nested = @ecs.to_nested(actual)
63
+ nested = actual.to_nested
65
64
 
66
65
  assert_equal 'get', nested[:http][:request][:method]
67
66
  assert_equal 112, nested[:http][:request][:body][:bytes]
@@ -73,7 +72,7 @@ describe ElasticCommonSchema do
73
72
  first = { id: 1, name: 'petshop' }
74
73
  second = { level: 'debug', code: 5 }
75
74
 
76
- actual = @ecs.deep_merge(first, second)
75
+ actual = first.deep_merge(second)
77
76
 
78
77
  assert_equal 1, actual[:id]
79
78
  assert_equal 'petshop', actual[:name]
@@ -85,7 +84,7 @@ describe ElasticCommonSchema do
85
84
  first = { id: 1, name: 'petshop', level: 'debug' }
86
85
  second = { name: 'petstore', level: 'error', code: 5 }
87
86
 
88
- actual = @ecs.deep_merge(first, second)
87
+ actual = first.deep_merge(second)
89
88
 
90
89
  assert_equal 1, actual[:id]
91
90
  assert_equal 'petstore', actual[:name]
@@ -97,7 +96,7 @@ describe ElasticCommonSchema do
97
96
  first = { service: { name: 'petshop' } }
98
97
  second = { service: { id: 'ps001' } }
99
98
 
100
- actual = @ecs.deep_merge(first, second)
99
+ actual = first.deep_merge(second)
101
100
  assert_equal 'petshop', actual[:service][:name]
102
101
  assert_equal 'ps001', actual[:service][:id]
103
102
  end
@@ -106,7 +105,7 @@ describe ElasticCommonSchema do
106
105
  first = { http: { request: { method: 'get', bytes: 124 } } }
107
106
  second = { http: { response: { status_code: 200, bytes: 5001 } } }
108
107
 
109
- actual = @ecs.deep_merge(first, second)
108
+ actual = first.deep_merge(second)
110
109
 
111
110
  assert_equal 'get', actual[:http][:request][:method]
112
111
  assert_equal 124, actual[:http][:request][:bytes]
@@ -118,7 +117,7 @@ describe ElasticCommonSchema do
118
117
  first = {}
119
118
  second = { id: 1 }
120
119
 
121
- actual = @ecs.deep_merge(first, second)
120
+ actual = first.deep_merge(second)
122
121
 
123
122
  assert_equal 1, actual[:id]
124
123
  end
@@ -127,7 +126,7 @@ describe ElasticCommonSchema do
127
126
  first = { id: 1 }
128
127
  second = {}
129
128
 
130
- actual = @ecs.deep_merge(first, second)
129
+ actual = first.deep_merge(second)
131
130
 
132
131
  assert_equal 1, actual[:id]
133
132
  end
@@ -7,178 +7,288 @@ describe Twiglet::Logger do
7
7
  before do
8
8
  @now = -> { Time.utc(2020, 5, 11, 15, 1, 1) }
9
9
  @buffer = StringIO.new
10
- @logger = Twiglet::Logger.new(conf: {
11
- service: 'petshop',
12
- now: @now,
13
- output: @buffer
14
- })
10
+ @logger = Twiglet::Logger.new('petshop',
11
+ now: @now,
12
+ output: @buffer)
15
13
  end
16
14
 
15
+ LEVELS = [
16
+ { method: :debug, level: 'debug' },
17
+ { method: :info, level: 'info' },
18
+ { method: :warning, level: 'warn' },
19
+ { method: :warn, level: 'warn' },
20
+ { method: :critical, level: 'fatal' },
21
+ { method: :fatal, level: 'fatal' },
22
+ { method: :error, level: 'error' }
23
+ ].freeze
24
+
17
25
  it 'should throw an error with an empty service name' do
18
26
  assert_raises RuntimeError do
19
- Twiglet::Logger.new(conf: { service: ' ' })
27
+ Twiglet::Logger.new(' ')
20
28
  end
21
29
  end
22
30
 
23
- it 'should throw an error with an empty message' do
24
- assert_raises RuntimeError do
25
- @logger.info('')
31
+ it 'conforms to the standard Ruby Logger API' do
32
+ [:debug, :debug?, :info, :info?, :warn, :warn?, :fatal, :fatal?, :error, :error?,
33
+ :level, :level=, :sev_threshold=].each do |call|
34
+ assert @logger.respond_to?(call), "Logger does not respond to #{call}"
26
35
  end
27
36
  end
28
37
 
29
- it 'should log mandatory attributes' do
30
- @logger.error({ message: 'Out of pets exception' })
31
- actual_log = read_json(@buffer)
32
-
33
- expected_log = {
34
- message: 'Out of pets exception',
35
- "@timestamp": '2020-05-11T15:01:01.000Z',
36
- service: {
37
- name: 'petshop'
38
- },
39
- log: {
40
- level: 'error'
38
+ describe 'JSON logging' do
39
+ it 'should throw an error with an empty message' do
40
+ assert_raises RuntimeError do
41
+ @logger.info({message: ''})
42
+ end
43
+ end
44
+
45
+ it 'should log mandatory attributes' do
46
+ @logger.error({message: 'Out of pets exception'})
47
+ actual_log = read_json(@buffer)
48
+
49
+ expected_log = {
50
+ message: 'Out of pets exception',
51
+ "@timestamp": '2020-05-11T15:01:01.000Z',
52
+ service: {
53
+ name: 'petshop'
54
+ },
55
+ log: {
56
+ level: 'error'
57
+ }
41
58
  }
42
- }
43
59
 
44
- assert_equal expected_log, actual_log
45
- end
60
+ assert_equal expected_log, actual_log
61
+ end
46
62
 
47
- it 'should log the provided message' do
48
- @logger.error({ event:
49
- { action: 'exception' },
50
- message: 'Emergency! Emergency!' })
51
- log = read_json(@buffer)
63
+ it 'should log the provided message' do
64
+ @logger.error({event:
65
+ {action: 'exception'},
66
+ message: 'Emergency! Emergency!'})
67
+ log = read_json(@buffer)
52
68
 
53
- assert_equal 'exception', log[:event][:action]
54
- assert_equal 'Emergency! Emergency!', log[:message]
55
- end
69
+ assert_equal 'exception', log[:event][:action]
70
+ assert_equal 'Emergency! Emergency!', log[:message]
71
+ end
72
+
73
+ it 'should log scoped properties defined at creation' do
74
+ extra_properties = {
75
+ trace: {
76
+ id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb'
77
+ },
78
+ service: {
79
+ type: 'shop'
80
+ },
81
+ request: {method: 'get'},
82
+ response: {status_code: 200}
83
+ }
56
84
 
57
- it 'should log scoped properties defined at creation' do
58
- extra_properties = {
59
- trace: {
60
- id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb'
61
- },
62
- request: { method: 'get' },
63
- response: { status_code: 200 }
64
- }
65
-
66
- output = StringIO.new
67
- logger = Twiglet::Logger.new(conf: {
68
- service: 'petshop',
85
+ output = StringIO.new
86
+ logger = Twiglet::Logger.new('petshop',
69
87
  now: @now,
70
- output: output
71
- },
72
- scoped_properties: extra_properties)
88
+ output: output,
89
+ default_properties: extra_properties)
73
90
 
74
- logger.error({ message: 'GET /cats' })
75
- log = read_json output
91
+ logger.error({message: 'GET /cats'})
92
+ log = read_json output
76
93
 
77
- assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
78
- assert_equal 'get', log[:request][:method]
79
- assert_equal 200, log[:response][:status_code]
80
- end
94
+ assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
95
+ assert_equal 'petshop', log[:service][:name]
96
+ assert_equal 'shop', log[:service][:type]
97
+ assert_equal 'get', log[:request][:method]
98
+ assert_equal 200, log[:response][:status_code]
99
+ end
81
100
 
82
- it "should be able to add properties with '.with'" do
83
- # Let's add some context to this customer journey
84
- purchase_logger = @logger.with({
85
- trace: { id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb' },
86
- customer: { full_name: 'Freda Bloggs' },
87
- event: { action: 'pet purchase' }
88
- })
89
-
90
- # do stuff
91
- purchase_logger.info({
92
- message: 'customer bought a dog',
93
- pet: { name: 'Barker', species: 'dog', breed: 'Bitsa' }
94
- })
95
-
96
- log = read_json @buffer
97
-
98
- assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
99
- assert_equal 'Freda Bloggs', log[:customer][:full_name]
100
- assert_equal 'pet purchase', log[:event][:action]
101
- assert_equal 'customer bought a dog', log[:message]
102
- assert_equal 'Barker', log[:pet][:name]
103
- end
101
+ it "should be able to add properties with '.with'" do
102
+ # Let's add some context to this customer journey
103
+ purchase_logger = @logger.with({
104
+ trace: {id: '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb'},
105
+ customer: {full_name: 'Freda Bloggs'},
106
+ event: {action: 'pet purchase'}
107
+ })
104
108
 
105
- it "should log 'message' string property" do
106
- message = {}
107
- message['message'] = 'Guinea pigs arrived'
108
- @logger.debug(message)
109
- log = read_json(@buffer)
109
+ # do stuff
110
+ purchase_logger.info({
111
+ message: 'customer bought a dog',
112
+ pet: {name: 'Barker', species: 'dog', breed: 'Bitsa'}
113
+ })
110
114
 
111
- assert_equal 'Guinea pigs arrived', log[:message]
112
- end
115
+ log = read_json @buffer
113
116
 
114
- it 'should be able to convert dotted keys to nested objects' do
115
- @logger.debug({
116
- "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
117
- message: 'customer bought a dog',
118
- "pet.name": 'Barker',
119
- "pet.species": 'dog',
120
- "pet.breed": 'Bitsa'
121
- })
122
- log = read_json(@buffer)
123
-
124
- assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
125
- assert_equal 'customer bought a dog', log[:message]
126
- assert_equal 'Barker', log[:pet][:name]
127
- assert_equal 'dog', log[:pet][:species]
128
- assert_equal 'Bitsa', log[:pet][:breed]
129
- end
117
+ assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
118
+ assert_equal 'Freda Bloggs', log[:customer][:full_name]
119
+ assert_equal 'pet purchase', log[:event][:action]
120
+ assert_equal 'customer bought a dog', log[:message]
121
+ assert_equal 'Barker', log[:pet][:name]
122
+ end
130
123
 
131
- it 'should be able to mix dotted keys and nested objects' do
132
- @logger.debug({
133
- "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
134
- message: 'customer bought a dog',
135
- pet: { name: 'Barker', breed: 'Bitsa' },
136
- "pet.species": 'dog'
137
- })
138
- log = read_json(@buffer)
139
-
140
- assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
141
- assert_equal 'customer bought a dog', log[:message]
142
- assert_equal 'Barker', log[:pet][:name]
143
- assert_equal 'dog', log[:pet][:species]
144
- assert_equal 'Bitsa', log[:pet][:breed]
145
- end
124
+ it "should log 'message' string property" do
125
+ message = {}
126
+ message['message'] = 'Guinea pigs arrived'
127
+ @logger.debug(message)
128
+ log = read_json(@buffer)
129
+
130
+ assert_equal 'Guinea pigs arrived', log[:message]
131
+ end
132
+
133
+ it 'should be able to convert dotted keys to nested objects' do
134
+ @logger.debug({
135
+ "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
136
+ message: 'customer bought a dog',
137
+ "pet.name": 'Barker',
138
+ "pet.species": 'dog',
139
+ "pet.breed": 'Bitsa'
140
+ })
141
+ log = read_json(@buffer)
142
+
143
+ assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
144
+ assert_equal 'customer bought a dog', log[:message]
145
+ assert_equal 'Barker', log[:pet][:name]
146
+ assert_equal 'dog', log[:pet][:species]
147
+ assert_equal 'Bitsa', log[:pet][:breed]
148
+ end
149
+
150
+ it 'should be able to mix dotted keys and nested objects' do
151
+ @logger.debug({
152
+ "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb',
153
+ message: 'customer bought a dog',
154
+ pet: {name: 'Barker', breed: 'Bitsa'},
155
+ "pet.species": 'dog'
156
+ })
157
+ log = read_json(@buffer)
158
+
159
+ assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', log[:trace][:id]
160
+ assert_equal 'customer bought a dog', log[:message]
161
+ assert_equal 'Barker', log[:pet][:name]
162
+ assert_equal 'dog', log[:pet][:species]
163
+ assert_equal 'Bitsa', log[:pet][:breed]
164
+ end
165
+
166
+ it 'should work with mixed string and symbol properties' do
167
+ log = {
168
+ "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb'
169
+ }
170
+ event = {}
171
+ log['event'] = event
172
+ log['message'] = 'customer bought a dog'
173
+ pet = {}
174
+ pet['name'] = 'Barker'
175
+ pet['breed'] = 'Bitsa'
176
+ pet[:species] = 'dog'
177
+ log[:pet] = pet
178
+
179
+ @logger.debug(log)
180
+ actual_log = read_json(@buffer)
146
181
 
147
- it 'should work with mixed string and symbol properties' do
148
- log = {
149
- "trace.id": '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb'
150
- }
151
- event = {}
152
- log['event'] = event
153
- log['message'] = 'customer bought a dog'
154
- pet = {}
155
- pet['name'] = 'Barker'
156
- pet['breed'] = 'Bitsa'
157
- pet[:species] = 'dog'
158
- log[:pet] = pet
159
-
160
- @logger.debug(log)
161
- actual_log = read_json(@buffer)
162
-
163
- assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', actual_log[:trace][:id]
164
- assert_equal 'customer bought a dog', actual_log[:message]
165
- assert_equal 'Barker', actual_log[:pet][:name]
166
- assert_equal 'dog', actual_log[:pet][:species]
167
- assert_equal 'Bitsa', actual_log[:pet][:breed]
182
+ assert_equal '1c8a5fb2-fecd-44d8-92a4-449eb2ce4dcb', actual_log[:trace][:id]
183
+ assert_equal 'customer bought a dog', actual_log[:message]
184
+ assert_equal 'Barker', actual_log[:pet][:name]
185
+ assert_equal 'dog', actual_log[:pet][:species]
186
+ assert_equal 'Bitsa', actual_log[:pet][:breed]
187
+ end
188
+
189
+ it 'should log an error with backtrace' do
190
+ begin
191
+ 1 / 0
192
+ rescue StandardError => e
193
+ @logger.error({message: 'Artificially raised exception'}, e)
194
+ end
195
+
196
+ actual_log = read_json(@buffer)
197
+
198
+ assert_equal 'Artificially raised exception', actual_log[:message]
199
+ assert_equal 'divided by 0', actual_log[:error][:message]
200
+ assert_match 'logger_test.rb', actual_log[:error][:stack_trace].lines.first
201
+ end
202
+
203
+ it 'should log an error without backtrace' do
204
+ e = StandardError.new('Connection timed-out')
205
+ @logger.error({message: 'Artificially raised exception'}, e)
206
+
207
+ actual_log = read_json(@buffer)
208
+
209
+ assert_equal 'Artificially raised exception', actual_log[:message]
210
+ assert_equal 'Connection timed-out', actual_log[:error][:message]
211
+ refute actual_log[:error].key?(:stack_trace)
212
+ end
213
+
214
+ LEVELS.each do |attrs|
215
+ it "should correctly log level when calling #{attrs[:method]}" do
216
+ @logger.public_send(attrs[:method], {message: 'a log message'})
217
+ actual_log = read_json(@buffer)
218
+
219
+ assert_equal attrs[:level], actual_log[:log][:level]
220
+ assert_equal 'a log message', actual_log[:message]
221
+ end
222
+ end
168
223
  end
169
224
 
170
- it 'should log an error with backtrace' do
171
- begin
172
- 1 / 0
173
- rescue StandardError => e
174
- @logger.error({ message: 'Artificially raised exception' }, e)
225
+ describe 'text logging' do
226
+ it 'should throw an error with an empty message' do
227
+ assert_raises RuntimeError do
228
+ @logger.info('')
229
+ end
230
+ end
231
+
232
+ it 'should log mandatory attributes' do
233
+ @logger.error('Out of pets exception')
234
+ actual_log = read_json(@buffer)
235
+
236
+ expected_log = {
237
+ message: 'Out of pets exception',
238
+ "@timestamp": '2020-05-11T15:01:01.000Z',
239
+ service: {
240
+ name: 'petshop'
241
+ },
242
+ log: {
243
+ level: 'error'
244
+ }
245
+ }
246
+
247
+ assert_equal expected_log, actual_log
248
+ end
249
+
250
+ it 'should log the provided message' do
251
+ @logger.error('Emergency! Emergency!')
252
+ log = read_json(@buffer)
253
+
254
+ assert_equal 'Emergency! Emergency!', log[:message]
255
+ end
256
+
257
+ LEVELS.each do |attrs|
258
+ it "should correctly log level when calling #{attrs[:method]}" do
259
+ @logger.public_send(attrs[:method], 'a log message')
260
+ actual_log = read_json(@buffer)
261
+
262
+ assert_equal attrs[:level], actual_log[:log][:level]
263
+ assert_equal 'a log message', actual_log[:message]
264
+ end
175
265
  end
266
+ end
176
267
 
177
- actual_log = read_json(@buffer)
268
+ describe 'logging with a block' do
269
+ LEVELS.each do |attrs|
270
+ it "should correctly log the block when calling #{attrs[:method]}" do
271
+ block = proc { 'a block log message' }
272
+ @logger.public_send(attrs[:method], &block)
273
+ actual_log = read_json(@buffer)
178
274
 
179
- assert_equal 'Artificially raised exception', actual_log[:message]
180
- assert_equal 'divided by 0', actual_log[:error_name]
181
- assert_match 'logger_test.rb', actual_log[:backtrace].first
275
+ assert_equal attrs[:level], actual_log[:log][:level]
276
+ assert_equal 'a block log message', actual_log[:message]
277
+ end
278
+ end
279
+ end
280
+
281
+ describe 'logger level' do
282
+ [
283
+ { expression: :info, level: 1 },
284
+ { expression: 'warn', level: 2 },
285
+ { expression: Logger::DEBUG, level: 0 }
286
+ ].each do |args|
287
+ it "sets the severity threshold to level #{args[:level]}" do
288
+ @logger.level = args[:expression]
289
+ assert_equal args[:level], @logger.level
290
+ end
291
+ end
182
292
  end
183
293
 
184
294
  private
@@ -9,7 +9,7 @@ Gem::Specification.new do |gem|
9
9
  gem.version = Twiglet::VERSION
10
10
  gem.authors = ['Simply Business']
11
11
  gem.email = ['tech@simplybusiness.co.uk']
12
- gem.homepage = 'https://github.com/simplybusiness/twiglet'
12
+ gem.homepage = 'https://github.com/simplybusiness/twiglet-ruby'
13
13
 
14
14
  gem.summary = 'Twiglet'
15
15
  gem.description = 'Like a log, only smaller.'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: twiglet
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simply Business
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-01 00:00:00.000000000 Z
11
+ date: 2020-06-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Like a log, only smaller.
14
14
  email:
@@ -21,19 +21,24 @@ files:
21
21
  - ".github/workflows/ruby.yml"
22
22
  - ".gitignore"
23
23
  - ".rubocop.yml"
24
+ - ".ruby-version"
24
25
  - CODE_OF_CONDUCT.md
25
26
  - Gemfile
26
27
  - Gemfile.lock
27
28
  - LICENSE
29
+ - RATIONALE.md
28
30
  - README.md
31
+ - Rakefile
29
32
  - example_app.rb
30
- - lib/elastic_common_schema.rb
33
+ - lib/hash_extensions.rb
34
+ - lib/twiglet/formatter.rb
31
35
  - lib/twiglet/logger.rb
32
36
  - lib/twiglet/version.rb
33
- - test/elastic_common_schema_test.rb
37
+ - test/formatter_test.rb
38
+ - test/hash_extensions_test.rb
34
39
  - test/logger_test.rb
35
40
  - twiglet.gemspec
36
- homepage: https://github.com/simplybusiness/twiglet
41
+ homepage: https://github.com/simplybusiness/twiglet-ruby
37
42
  licenses:
38
43
  - Copyright SimplyBusiness
39
44
  metadata: {}
@@ -52,7 +57,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
57
  - !ruby/object:Gem::Version
53
58
  version: '0'
54
59
  requirements: []
55
- rubygems_version: 3.0.3
60
+ rubygems_version: 3.0.8
56
61
  signing_key:
57
62
  specification_version: 4
58
63
  summary: Twiglet
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ElasticCommonSchema
4
- def to_nested(log)
5
- log unless contains_dotted_key?(log)
6
-
7
- log.keys.reduce({}) do |nested, key|
8
- deep_merge(nested, build_nested_object(key, log[key]))
9
- end
10
- end
11
-
12
- def deep_merge(hash1, hash2)
13
- merger = proc { |_, val1, val2| val1.is_a?(Hash) && val2.is_a?(Hash) ? val1.merge(val2, &merger) : val2 }
14
- hash1.merge(hash2, &merger)
15
- end
16
-
17
- private
18
-
19
- def contains_dotted_key?(log)
20
- log.keys.any? { |x| x.to_s.include?('.') }
21
- end
22
-
23
- def build_nested_object(key, val)
24
- key.to_s
25
- .split('.')
26
- .reverse
27
- .reduce(val) { |nested, key_part| Hash[key_part.to_sym, nested] }
28
- end
29
- end